diff --git a/application/src/main/java/org/thingsboard/server/controller/AdminController.java b/application/src/main/java/org/thingsboard/server/controller/AdminController.java index dfefa9c755..5cf97a7cda 100644 --- a/application/src/main/java/org/thingsboard/server/controller/AdminController.java +++ b/application/src/main/java/org/thingsboard/server/controller/AdminController.java @@ -42,6 +42,7 @@ import org.thingsboard.server.common.data.AdminSettings; import org.thingsboard.server.common.data.FeaturesInfo; import org.thingsboard.server.common.data.SystemInfo; import org.thingsboard.server.common.data.UpdateMessage; +import org.thingsboard.server.common.data.audit.ActionType; import org.thingsboard.server.common.data.exception.ThingsboardException; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.security.model.JwtPair; @@ -52,6 +53,7 @@ import org.thingsboard.server.common.data.sync.vc.AutoCommitSettings; import org.thingsboard.server.common.data.sync.vc.RepositorySettings; import org.thingsboard.server.common.data.sync.vc.RepositorySettingsInfo; import org.thingsboard.server.common.data.sync.vc.VcUtils; +import org.thingsboard.server.dao.audit.AuditLogService; import org.thingsboard.server.dao.settings.AdminSettingsService; import org.thingsboard.server.queue.util.TbCoreComponent; import org.thingsboard.server.service.security.auth.jwt.settings.JwtSettingsService; @@ -86,6 +88,7 @@ public class AdminController extends BaseController { private final TbAutoCommitSettingsService autoCommitSettingsService; private final UpdateService updateService; private final SystemInfoService systemInfoService; + private final AuditLogService auditLogService; @ApiOperation(value = "Get the Administration Settings object using key (getAdminSettings)", notes = "Get the Administration Settings object using specified string key. Referencing non-existing key will cause an error." + SYSTEM_AUTHORITY_PARAGRAPH) @@ -202,8 +205,15 @@ public class AdminController extends BaseController { public void sendTestSms( @ApiParam(value = "A JSON value representing the Test SMS request.") @RequestBody TestSmsRequest testSmsRequest) throws ThingsboardException { - accessControlService.checkPermission(getCurrentUser(), Resource.ADMIN_SETTINGS, Operation.READ); - smsService.sendTestSms(testSmsRequest); + SecurityUser user = getCurrentUser(); + accessControlService.checkPermission(user, Resource.ADMIN_SETTINGS, Operation.READ); + try { + smsService.sendTestSms(testSmsRequest); + auditLogService.logEntityAction(user.getTenantId(), user.getCustomerId(), user.getId(), user.getName(), user.getId(), user, ActionType.SMS_SENT, null, testSmsRequest.getNumberTo()); + } catch (ThingsboardException e) { + auditLogService.logEntityAction(user.getTenantId(), user.getCustomerId(), user.getId(), user.getName(), user.getId(), user, ActionType.SMS_SENT, e, testSmsRequest.getNumberTo()); + throw e; + } } @ApiOperation(value = "Get repository settings (getRepositorySettings)", diff --git a/application/src/main/java/org/thingsboard/server/service/apiusage/TenantApiUsageState.java b/application/src/main/java/org/thingsboard/server/service/apiusage/TenantApiUsageState.java index fcba410bbd..83a8e52892 100644 --- a/application/src/main/java/org/thingsboard/server/service/apiusage/TenantApiUsageState.java +++ b/application/src/main/java/org/thingsboard/server/service/apiusage/TenantApiUsageState.java @@ -55,6 +55,10 @@ public class TenantApiUsageState extends BaseApiUsageState { return tenantProfileData.getConfiguration().getProfileThreshold(key); } + public boolean getProfileFeatureEnabled(ApiUsageRecordKey key) { + return tenantProfileData.getConfiguration().getProfileFeatureEnabled(key); + } + public long getProfileWarnThreshold(ApiUsageRecordKey key) { return tenantProfileData.getConfiguration().getWarnThreshold(key); } @@ -63,13 +67,18 @@ public class TenantApiUsageState extends BaseApiUsageState { ApiUsageStateValue featureValue = ApiUsageStateValue.ENABLED; for (ApiUsageRecordKey recordKey : ApiUsageRecordKey.getKeys(feature)) { long value = get(recordKey); + boolean featureEnabled = getProfileFeatureEnabled(recordKey); long threshold = getProfileThreshold(recordKey); long warnThreshold = getProfileWarnThreshold(recordKey); ApiUsageStateValue tmpValue; - if (threshold == 0 || value == 0 || value < warnThreshold) { - tmpValue = ApiUsageStateValue.ENABLED; - } else if (value < threshold) { - tmpValue = ApiUsageStateValue.WARNING; + if (featureEnabled) { + if (threshold == 0 || value == 0 || value < warnThreshold) { + tmpValue = ApiUsageStateValue.ENABLED; + } else if (value < threshold) { + tmpValue = ApiUsageStateValue.WARNING; + } else { + tmpValue = ApiUsageStateValue.DISABLED; + } } else { tmpValue = ApiUsageStateValue.DISABLED; } diff --git a/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/provider/impl/SmsTwoFaProvider.java b/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/provider/impl/SmsTwoFaProvider.java index 75e5a01441..65a197153f 100644 --- a/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/provider/impl/SmsTwoFaProvider.java +++ b/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/provider/impl/SmsTwoFaProvider.java @@ -20,12 +20,14 @@ import org.springframework.stereotype.Service; import org.thingsboard.rule.engine.api.SmsService; import org.thingsboard.rule.engine.api.util.TbNodeUtils; import org.thingsboard.server.common.data.User; +import org.thingsboard.server.common.data.audit.ActionType; import org.thingsboard.server.common.data.exception.ThingsboardErrorCode; import org.thingsboard.server.common.data.exception.ThingsboardException; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.security.model.mfa.account.SmsTwoFaAccountConfig; import org.thingsboard.server.common.data.security.model.mfa.provider.SmsTwoFaProviderConfig; import org.thingsboard.server.common.data.security.model.mfa.provider.TwoFaProviderType; +import org.thingsboard.server.dao.audit.AuditLogService; import org.thingsboard.server.queue.util.TbCoreComponent; import org.thingsboard.server.service.security.model.SecurityUser; @@ -36,10 +38,12 @@ import java.util.Map; public class SmsTwoFaProvider extends OtpBasedTwoFaProvider { private final SmsService smsService; + private final AuditLogService auditLogService; - public SmsTwoFaProvider(CacheManager cacheManager, SmsService smsService) { + public SmsTwoFaProvider(CacheManager cacheManager, SmsService smsService, AuditLogService auditLogService) { super(cacheManager); this.smsService = smsService; + this.auditLogService = auditLogService; } @@ -56,8 +60,13 @@ public class SmsTwoFaProvider extends OtpBasedTwoFaProvider { + defaultSmsService.sendSms(tenantId, null, new String[]{RandomStringUtils.randomNumeric(10)}, "Message"); + }, "SMS sending is disabled due to API limits!"); + } + + @Test + public void testLimitSmsMessagingIfSmsDisabled() throws Exception { + tenantProfile = getDefaultTenantProfile(); + + DefaultTenantProfileConfiguration config = createTenantProfileConfigurationWithSmsLimits(0, false); + saveTenantProfileWitConfiguration(tenantProfile, config); + + TimeUnit.SECONDS.sleep(1); + assertThrows(RuntimeException.class, () -> { + defaultSmsService.sendSms(tenantId, null, new String[]{RandomStringUtils.randomNumeric(10)}, "Message"); + }, "SMS sending is disabled due to API limits!"); + + //enable sms messaging + DefaultTenantProfileConfiguration config2 = createTenantProfileConfigurationWithSmsLimits(0, true); + saveTenantProfileWitConfiguration(tenantProfile, config2); + TimeUnit.SECONDS.sleep(1); + + for (int i = 0; i < 10; i++) { + doReturn(1).when(defaultSmsService).sendSms(any(), any()); + defaultSmsService.sendSms(tenantId, null, new String[]{RandomStringUtils.randomNumeric(10)}, "Message"); + } + } + + private TenantProfile getDefaultTenantProfile() throws Exception { + + PageLink pageLink = new PageLink(17); + PageData pageData = doGetTypedWithPageLink("/api/tenantProfiles?", + new TypeReference<>(){}, pageLink); + Assert.assertFalse(pageData.hasNext()); + Assert.assertEquals(1, pageData.getTotalElements()); + List tenantProfiles = new ArrayList<>(pageData.getData()); + + Optional optionalDefaultProfile = tenantProfiles.stream().filter(TenantProfile::isDefault).reduce((a, b) -> null); + Assert.assertTrue(optionalDefaultProfile.isPresent()); + + return optionalDefaultProfile.get(); + } + + private DefaultTenantProfileConfiguration createTenantProfileConfigurationWithSmsLimits(Integer maxSms, Boolean smsEnabled) { + DefaultTenantProfileConfiguration.DefaultTenantProfileConfigurationBuilder builder = DefaultTenantProfileConfiguration.builder(); + builder.maxSms(maxSms); + builder.smsEnabled(smsEnabled); + return builder.build(); + + } + + private void saveTenantProfileWitConfiguration(TenantProfile tenantProfile, TenantProfileConfiguration tenantProfileConfiguration) { + TenantProfileData tenantProfileData = tenantProfile.getProfileData(); + tenantProfileData.setConfiguration(tenantProfileConfiguration); + TenantProfile savedTenantProfile = doPost("/api/tenantProfile", tenantProfile, TenantProfile.class); + Assert.assertNotNull(savedTenantProfile); + } + + private void prepareSmsSystemSetting() throws Exception { + if (doGet("/api/admin/settings/sms").andReturn().getResponse().getStatus() == 404) { + AdminSettings adminSettings = new AdminSettings(); + ObjectNode value = JacksonUtil.newObjectNode(); + value.put("numberFrom", "+12543223870"); + value.put("accountSid", "testAcc"); + value.put("accountToken", "testToken"); + value.put("type", "TWILIO"); + adminSettings.setKey("sms"); + adminSettings.setJsonValue(value); + + doPost("/api/admin/settings", adminSettings).andExpect(status().isOk()); + } + } +} \ No newline at end of file diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/UsageInfo.java b/common/data/src/main/java/org/thingsboard/server/common/data/UsageInfo.java index 297e050011..1d5023d270 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/UsageInfo.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/UsageInfo.java @@ -38,6 +38,7 @@ public class UsageInfo { private long maxEmails; private long sms; private long maxSms; + private boolean smsEnabled; private long alarms; private long maxAlarms; } \ No newline at end of file diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/audit/ActionType.java b/common/data/src/main/java/org/thingsboard/server/common/data/audit/ActionType.java index 51f1e27d24..01009751d4 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/audit/ActionType.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/audit/ActionType.java @@ -53,7 +53,8 @@ public enum ActionType { UNASSIGNED_FROM_EDGE(false), ADDED_COMMENT(false), UPDATED_COMMENT(false), - DELETED_COMMENT(false); + DELETED_COMMENT(false), + SMS_SENT(false); private final boolean isRead; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/tenant/profile/DefaultTenantProfileConfiguration.java b/common/data/src/main/java/org/thingsboard/server/common/data/tenant/profile/DefaultTenantProfileConfiguration.java index 66fba5f832..09dcf2ecb2 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/tenant/profile/DefaultTenantProfileConfiguration.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/tenant/profile/DefaultTenantProfileConfiguration.java @@ -57,6 +57,7 @@ public class DefaultTenantProfileConfiguration implements TenantProfileConfigura private long maxDPStorageDays; private int maxRuleNodeExecutionsPerMessage; private long maxEmails; + private Boolean smsEnabled; private long maxSms; private long maxCreatedAlarms; @@ -105,6 +106,16 @@ public class DefaultTenantProfileConfiguration implements TenantProfileConfigura return 0L; } + @Override + public boolean getProfileFeatureEnabled(ApiUsageRecordKey key) { + switch (key) { + case SMS_EXEC_COUNT: + return smsEnabled == null || smsEnabled; + default: + return true; + } + } + @Override public long getWarnThreshold(ApiUsageRecordKey key) { return (long) (getProfileThreshold(key) * (warnThreshold > 0.0 ? warnThreshold : 0.8)); diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/tenant/profile/TenantProfileConfiguration.java b/common/data/src/main/java/org/thingsboard/server/common/data/tenant/profile/TenantProfileConfiguration.java index 3947abb668..dacff693b7 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/tenant/profile/TenantProfileConfiguration.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/tenant/profile/TenantProfileConfiguration.java @@ -37,6 +37,9 @@ public interface TenantProfileConfiguration { @JsonIgnore long getProfileThreshold(ApiUsageRecordKey key); + @JsonIgnore + boolean getProfileFeatureEnabled(ApiUsageRecordKey key); + @JsonIgnore long getWarnThreshold(ApiUsageRecordKey key); diff --git a/dao/src/main/java/org/thingsboard/server/dao/audit/AuditLogServiceImpl.java b/dao/src/main/java/org/thingsboard/server/dao/audit/AuditLogServiceImpl.java index 08f196c9d7..4ae3c265c5 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/audit/AuditLogServiceImpl.java +++ b/dao/src/main/java/org/thingsboard/server/dao/audit/AuditLogServiceImpl.java @@ -326,6 +326,10 @@ public class AuditLogServiceImpl implements AuditLogService { actionData.put("unassignedEdgeId", strEdgeId); actionData.put("unassignedEdgeName", strEdgeName); break; + case SMS_SENT: + String number = extractParameter(String.class, 0, additionalInfo); + actionData.put("recipientNumber", number); + break; } return actionData; } diff --git a/dao/src/main/java/org/thingsboard/server/dao/usage/BasicUsageInfoService.java b/dao/src/main/java/org/thingsboard/server/dao/usage/BasicUsageInfoService.java index cf540a205d..fcd75b25f7 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/usage/BasicUsageInfoService.java +++ b/dao/src/main/java/org/thingsboard/server/dao/usage/BasicUsageInfoService.java @@ -69,6 +69,7 @@ public class BasicUsageInfoService implements UsageInfoService { usageInfo.setMaxJsExecutions(profileConfiguration.getMaxJSExecutions()); usageInfo.setMaxEmails(profileConfiguration.getMaxEmails()); usageInfo.setMaxSms(profileConfiguration.getMaxSms()); + usageInfo.setSmsEnabled(profileConfiguration.getSmsEnabled()); ApiUsageState apiUsageState = apiUsageStateService.findTenantApiUsageState(tenantId); if (apiUsageState != null) { Collection keys = Arrays.asList( diff --git a/ui-ngx/src/app/modules/home/components/profile/tenant/default-tenant-profile-configuration.component.html b/ui-ngx/src/app/modules/home/components/profile/tenant/default-tenant-profile-configuration.component.html index 0bbb6a5926..ce2f086d4d 100644 --- a/ui-ngx/src/app/modules/home/components/profile/tenant/default-tenant-profile-configuration.component.html +++ b/ui-ngx/src/app/modules/home/components/profile/tenant/default-tenant-profile-configuration.component.html @@ -253,7 +253,22 @@ {{ 'tenant-profile.alarms-and-notifications' | translate }} tenant-profile.unlimited -
+ + {{ 'tenant-profile.sms-enabled' | translate }} + + + tenant-profile.max-sms + + + {{ 'tenant-profile.max-sms-required' | translate}} + + + {{ 'tenant-profile.max-sms-range' | translate}} + + +
tenant-profile.max-emails
-
- - tenant-profile.max-sms - - - {{ 'tenant-profile.max-sms-required' | translate}} - - - {{ 'tenant-profile.max-sms-range' | translate}} - - -
-
diff --git a/ui-ngx/src/app/modules/home/components/profile/tenant/default-tenant-profile-configuration.component.scss b/ui-ngx/src/app/modules/home/components/profile/tenant/default-tenant-profile-configuration.component.scss index d2442a555e..c3d6278398 100644 --- a/ui-ngx/src/app/modules/home/components/profile/tenant/default-tenant-profile-configuration.component.scss +++ b/ui-ngx/src/app/modules/home/components/profile/tenant/default-tenant-profile-configuration.component.scss @@ -18,6 +18,10 @@ padding-top: 15px; } + .slide-toggle-element { + margin: 7px 0 13px; + } + .group-title > span { color: rgba(0, 0, 0, 0.54); } diff --git a/ui-ngx/src/app/modules/home/components/profile/tenant/default-tenant-profile-configuration.component.ts b/ui-ngx/src/app/modules/home/components/profile/tenant/default-tenant-profile-configuration.component.ts index cbb5c7444e..a4915f924b 100644 --- a/ui-ngx/src/app/modules/home/components/profile/tenant/default-tenant-profile-configuration.component.ts +++ b/ui-ngx/src/app/modules/home/components/profile/tenant/default-tenant-profile-configuration.component.ts @@ -14,7 +14,7 @@ /// limitations under the License. /// -import { Component, forwardRef, Input, OnInit } from '@angular/core'; +import { Component, forwardRef, Input, OnDestroy, OnInit } from '@angular/core'; import { ControlValueAccessor, UntypedFormBuilder, UntypedFormGroup, NG_VALUE_ACCESSOR, Validators } from '@angular/forms'; import { Store } from '@ngrx/store'; import { AppState } from '@app/core/core.state'; @@ -22,6 +22,8 @@ import { coerceBooleanProperty } from '@angular/cdk/coercion'; import { DefaultTenantProfileConfiguration, TenantProfileConfiguration } from '@shared/models/tenant.model'; import { isDefinedAndNotNull } from '@core/utils'; import { RateLimitsType } from './rate-limits/rate-limits.models'; +import { takeUntil } from 'rxjs/operators'; +import { Subject } from 'rxjs'; @Component({ selector: 'tb-default-tenant-profile-configuration', @@ -33,11 +35,12 @@ import { RateLimitsType } from './rate-limits/rate-limits.models'; multi: true }] }) -export class DefaultTenantProfileConfigurationComponent implements ControlValueAccessor, OnInit { +export class DefaultTenantProfileConfigurationComponent implements ControlValueAccessor, OnInit, OnDestroy { defaultTenantProfileConfigurationFormGroup: UntypedFormGroup; private requiredValue: boolean; + private destroy$ = new Subject(); get required(): boolean { return this.requiredValue; } @@ -81,7 +84,8 @@ export class DefaultTenantProfileConfigurationComponent implements ControlValueA maxDPStorageDays: [null, [Validators.required, Validators.min(0)]], maxRuleNodeExecutionsPerMessage: [null, [Validators.required, Validators.min(0)]], maxEmails: [null, [Validators.required, Validators.min(0)]], - maxSms: [null, [Validators.required, Validators.min(0)]], + maxSms: [null, []], + smsEnabled: [null, []], maxCreatedAlarms: [null, [Validators.required, Validators.min(0)]], defaultStorageTtlDays: [null, [Validators.required, Validators.min(0)]], alarmsTtlDays: [null, [Validators.required, Validators.min(0)]], @@ -100,11 +104,33 @@ export class DefaultTenantProfileConfigurationComponent implements ControlValueA wsUpdatesPerSessionRateLimit: [null, []], cassandraQueryTenantRateLimitsConfiguration: [null, []] }); + + this.defaultTenantProfileConfigurationFormGroup.get('smsEnabled').valueChanges.pipe( + takeUntil(this.destroy$) + ).subscribe((value: boolean) => { + this.maxSmsValidation(value); + } + ); + this.defaultTenantProfileConfigurationFormGroup.valueChanges.subscribe(() => { this.updateModel(); }); } + private maxSmsValidation(smsEnabled: boolean) { + if (smsEnabled) { + this.defaultTenantProfileConfigurationFormGroup.get('maxSms').addValidators([Validators.required, Validators.min(0)]); + } else { + this.defaultTenantProfileConfigurationFormGroup.get('maxSms').clearValidators(); + } + this.defaultTenantProfileConfigurationFormGroup.get('maxSms').updateValueAndValidity({emitEvent: false}); + } + + ngOnDestroy(): void { + this.destroy$.next(); + this.destroy$.complete(); + } + registerOnChange(fn: any): void { this.propagateChange = fn; } @@ -126,6 +152,7 @@ export class DefaultTenantProfileConfigurationComponent implements ControlValueA writeValue(value: DefaultTenantProfileConfiguration | null): void { if (isDefinedAndNotNull(value)) { + this.maxSmsValidation(value.smsEnabled); this.defaultTenantProfileConfigurationFormGroup.patchValue(value, {emitEvent: false}); } } diff --git a/ui-ngx/src/app/shared/models/audit-log.models.ts b/ui-ngx/src/app/shared/models/audit-log.models.ts index a2f7de9242..ce4d380567 100644 --- a/ui-ngx/src/app/shared/models/audit-log.models.ts +++ b/ui-ngx/src/app/shared/models/audit-log.models.ts @@ -62,7 +62,8 @@ export enum ActionType { TIMESERIES_UPDATED = 'TIMESERIES_UPDATED', TIMESERIES_DELETED = 'TIMESERIES_DELETED', ASSIGNED_TO_EDGE = 'ASSIGNED_TO_EDGE', - UNASSIGNED_FROM_EDGE = 'UNASSIGNED_FROM_EDGE' + UNASSIGNED_FROM_EDGE = 'UNASSIGNED_FROM_EDGE', + SMS_SENT = 'SMS_SENT' } export enum ActionStatus { @@ -105,7 +106,8 @@ export const actionTypeTranslations = new Map( [ActionType.TIMESERIES_UPDATED, 'audit-log.type-timeseries-updated'], [ActionType.TIMESERIES_DELETED, 'audit-log.type-timeseries-deleted'], [ActionType.ASSIGNED_TO_EDGE, 'audit-log.type-assigned-to-edge'], - [ActionType.UNASSIGNED_FROM_EDGE, 'audit-log.type-unassigned-from-edge'] + [ActionType.UNASSIGNED_FROM_EDGE, 'audit-log.type-unassigned-from-edge'], + [ActionType.SMS_SENT, 'audit-log.type-sms-sent'], ] ); diff --git a/ui-ngx/src/app/shared/models/tenant.model.ts b/ui-ngx/src/app/shared/models/tenant.model.ts index da6eb3417c..9074d599f5 100644 --- a/ui-ngx/src/app/shared/models/tenant.model.ts +++ b/ui-ngx/src/app/shared/models/tenant.model.ts @@ -54,6 +54,7 @@ export interface DefaultTenantProfileConfiguration { maxRuleNodeExecutionsPerMessage: number; maxEmails: number; maxSms: number; + smsEnabled: boolean; maxCreatedAlarms: number; tenantServerRestLimitsConfiguration: string; @@ -105,6 +106,7 @@ export function createTenantProfileConfiguration(type: TenantProfileType): Tenan maxRuleNodeExecutionsPerMessage: 0, maxEmails: 0, maxSms: 0, + smsEnabled: true, maxCreatedAlarms: 0, tenantServerRestLimitsConfiguration: '', customerServerRestLimitsConfiguration: '', diff --git a/ui-ngx/src/assets/locale/locale.constant-en_US.json b/ui-ngx/src/assets/locale/locale.constant-en_US.json index ee081d9727..a44c75f77e 100644 --- a/ui-ngx/src/assets/locale/locale.constant-en_US.json +++ b/ui-ngx/src/assets/locale/locale.constant-en_US.json @@ -807,7 +807,8 @@ "type-provision-success": "Device provisioned", "type-provision-failure": "Device provisioning was failed", "type-timeseries-updated": "Telemetry updated", - "type-timeseries-deleted": "Telemetry deleted" + "type-timeseries-deleted": "Telemetry deleted", + "type-sms-sent": "SMS sent" }, "confirm-on-exit": { "message": "You have unsaved changes. Are you sure you want to leave this page?",