From 8f281babed70bfe54295b5260179d8716bc6e466 Mon Sep 17 00:00:00 2001 From: seaerchin <44049504+seaerchin@users.noreply.github.com> Date: Mon, 14 Jun 2021 10:32:21 +0800 Subject: [PATCH 01/16] feat(sms-limiting): email for disabling sms verification (#2133) * feat(templates): add html template for email when verification is disabled * feat(mail.utils.ts): added new method for generation of email html * feat(mail.service): new service method to send mail when sms verification disabled * chore(sms-verification-disabled.view): updated wording * test(mail.service.spec): adds tests for verification disabled email sending --- .../verification/verification.constants.ts | 1 + .../mail/__tests__/mail.service.spec.ts | 113 +++++++++++++++++- src/app/services/mail/mail.service.ts | 39 ++++++ src/app/services/mail/mail.types.ts | 6 + src/app/services/mail/mail.utils.ts | 8 ++ ...sms-verification-disabled.server.view.html | 38 ++++++ 6 files changed, 204 insertions(+), 1 deletion(-) create mode 100644 src/app/modules/verification/verification.constants.ts create mode 100644 src/app/views/templates/sms-verification-disabled.server.view.html diff --git a/src/app/modules/verification/verification.constants.ts b/src/app/modules/verification/verification.constants.ts new file mode 100644 index 0000000000..c344320aa6 --- /dev/null +++ b/src/app/modules/verification/verification.constants.ts @@ -0,0 +1 @@ +export const SMS_VERIFICATION_LIMIT = 10000 diff --git a/src/app/services/mail/__tests__/mail.service.spec.ts b/src/app/services/mail/__tests__/mail.service.spec.ts index 6d11281407..f331e6a5ce 100644 --- a/src/app/services/mail/__tests__/mail.service.spec.ts +++ b/src/app/services/mail/__tests__/mail.service.spec.ts @@ -1,9 +1,13 @@ +import ejs from 'ejs' import { cloneDeep } from 'lodash' import moment from 'moment-timezone' import { err, ok, okAsync } from 'neverthrow' import Mail, { Attachment } from 'nodemailer/lib/mailer' -import { MailSendError } from 'src/app/services/mail/mail.errors' +import { + MailGenerationError, + MailSendError, +} from 'src/app/services/mail/mail.errors' import { MailService } from 'src/app/services/mail/mail.service' import { AutoreplySummaryRenderData, @@ -14,6 +18,8 @@ import * as MailUtils from 'src/app/services/mail/mail.utils' import { HASH_EXPIRE_AFTER_SECONDS } from 'src/shared/util/verification' import { BounceType, IPopulatedForm, ISubmissionSchema } from 'src/types' +import { SMS_VERIFICATION_LIMIT } from '../../../modules/verification/verification.constants' + const MOCK_VALID_EMAIL = 'to@example.com' const MOCK_VALID_EMAIL_2 = 'to2@example.com' const MOCK_VALID_EMAIL_3 = 'to3@example.com' @@ -1243,4 +1249,109 @@ describe('mail.service', () => { expect(sendMailSpy).toHaveBeenCalledWith(expectedArgs) }) }) + + describe('sendSmsVerificationDisabledEmail', () => { + const MOCK_FORM_ID = 'mockFormId' + const MOCK_FORM_TITLE = 'You are all individuals!' + const MOCK_INVALID_EMAIL = 'something wrong@a' + + const MOCK_FORM: IPopulatedForm = { + permissionList: [ + { email: MOCK_VALID_EMAIL }, + { email: MOCK_VALID_EMAIL_2 }, + ], + admin: { + email: MOCK_VALID_EMAIL_3, + }, + title: MOCK_FORM_TITLE, + _id: MOCK_FORM_ID, + } as unknown as IPopulatedForm + + const MOCK_INVALID_EMAIL_FORM: IPopulatedForm = { + permissionList: [], + admin: { + email: MOCK_INVALID_EMAIL, + }, + title: MOCK_FORM_TITLE, + _id: MOCK_FORM_ID, + } as unknown as IPopulatedForm + + const generateExpectedMailOptions = async ( + count: number, + emailRecipients: string | string[], + ) => { + const result = await MailUtils.generateSmsVerificationDisabledHtml({ + formTitle: MOCK_FORM_TITLE, + formLink: `${MOCK_APP_URL}/${MOCK_FORM_ID}`, + smsVerificationLimit: SMS_VERIFICATION_LIMIT, + }).map((emailHtml) => { + return { + to: emailRecipients, + from: MOCK_SENDER_STRING, + html: emailHtml, + subject: '[FormSG] SMS Verification - Free Tier Limit Reached', + replyTo: MOCK_SENDER_EMAIL, + bcc: MOCK_SENDER_EMAIL, + } + }) + return result._unsafeUnwrap() + } + + it('should send verified sms disabled emails successfully', async () => { + // Arrange + // sendMail should return mocked success response + sendMailSpy.mockResolvedValueOnce('mockedSuccessResponse') + + // Act + const actualResult = await mailService.sendSmsVerificationDisabledEmail( + MOCK_FORM, + ) + const expectedMailOptions = await generateExpectedMailOptions(1000, [ + MOCK_VALID_EMAIL, + MOCK_VALID_EMAIL_2, + MOCK_VALID_EMAIL_3, + ]) + + // Assert + expect(actualResult._unsafeUnwrap()).toEqual(true) + // Check arguments passed to sendNodeMail + expect(sendMailSpy).toHaveBeenCalledTimes(1) + expect(sendMailSpy).toHaveBeenCalledWith(expectedMailOptions) + }) + + it('should return MailSendError when the provided email is invalid', async () => { + // Act + const actualResult = await mailService.sendSmsVerificationDisabledEmail( + MOCK_INVALID_EMAIL_FORM, + ) + + // Assert + expect(actualResult).toEqual( + err(new MailSendError('Invalid email error')), + ) + // Check arguments passed to sendNodeMail + expect(sendMailSpy).toHaveBeenCalledTimes(0) + }) + + it('should return MailGenerationError when the html template could not be created', async () => { + // Arrange + jest.spyOn(ejs, 'renderFile').mockRejectedValueOnce('no.') + + // Act + const actualResult = await mailService.sendSmsVerificationDisabledEmail( + MOCK_INVALID_EMAIL_FORM, + ) + + // Assert + expect(actualResult).toEqual( + err( + new MailGenerationError( + 'Error occurred whilst rendering mail template', + ), + ), + ) + // Check arguments passed to sendNodeMail + expect(sendMailSpy).toHaveBeenCalledTimes(0) + }) + }) }) diff --git a/src/app/services/mail/mail.service.ts b/src/app/services/mail/mail.service.ts index e3416d7677..f2be101651 100644 --- a/src/app/services/mail/mail.service.ts +++ b/src/app/services/mail/mail.service.ts @@ -10,10 +10,12 @@ import { BounceType, EmailAdminDataField, IEmailFormSchema, + IPopulatedForm, ISubmissionSchema, } from '../../../types' import config from '../../config/config' import { createLoggerWithLabel } from '../../config/logger' +import { SMS_VERIFICATION_LIMIT } from '../../modules/verification/verification.constants' import { EMAIL_HEADERS, EmailType } from './mail.constants' import { MailGenerationError, MailSendError } from './mail.errors' @@ -25,12 +27,14 @@ import { SendAutoReplyEmailsArgs, SendMailOptions, SendSingleAutoreplyMailArgs, + SmsVerificationDisabledData, } from './mail.types' import { generateAutoreplyHtml, generateAutoreplyPdf, generateBounceNotificationHtml, generateLoginOtpHtml, + generateSmsVerificationDisabledHtml, generateSubmissionToAdminHtml, generateVerificationOtpHtml, isToFieldValid, @@ -572,6 +576,41 @@ export class MailService { }), ) } + + /** + * Sends a email to the admin and collaborators of the form when the verified sms feature will be disabled. + * This happens only when the admin has hit a certain limit of sms verifications on his account + * @param form The form whose admin and collaborators will be issued the email + * @returns ok(true) when mail sending is successful + * @returns err(MailGenerationError) when there was an error in generating the html data for the mail + * @returns err(MailSendError) when there was an error in sending the mail + */ + sendSmsVerificationDisabledEmail = ( + form: Pick, + ): ResultAsync => { + const htmlData: SmsVerificationDisabledData = { + formTitle: form.title, + formLink: `${this.#appUrl}/${form._id}`, + smsVerificationLimit: SMS_VERIFICATION_LIMIT, + } + + return generateSmsVerificationDisabledHtml(htmlData).andThen((mailHtml) => { + const emailRecipients = form.permissionList + .map(({ email }) => email) + .concat(form.admin.email) + + const mailOptions: MailOptions = { + to: emailRecipients, + from: this.#senderFromString, + html: mailHtml, + subject: '[FormSG] SMS Verification - Free Tier Limit Reached', + replyTo: this.#senderMail, + bcc: this.#senderMail, + } + + return this.#sendNodeMail(mailOptions, { formId: form._id }) + }) + } } export default new MailService() diff --git a/src/app/services/mail/mail.types.ts b/src/app/services/mail/mail.types.ts index 5d1a62d392..6100de4ae3 100644 --- a/src/app/services/mail/mail.types.ts +++ b/src/app/services/mail/mail.types.ts @@ -86,3 +86,9 @@ export type BounceNotificationHtmlData = { bouncedRecipients: string appName: string } + +export type SmsVerificationDisabledData = { + formLink: string + formTitle: string + smsVerificationLimit: number +} diff --git a/src/app/services/mail/mail.utils.ts b/src/app/services/mail/mail.utils.ts index d6df270e53..148ac5a65e 100644 --- a/src/app/services/mail/mail.utils.ts +++ b/src/app/services/mail/mail.utils.ts @@ -14,6 +14,7 @@ import { AutoreplyHtmlData, AutoreplySummaryRenderData, BounceNotificationHtmlData, + SmsVerificationDisabledData, SubmissionToAdminHtmlData, } from './mail.types' @@ -169,3 +170,10 @@ export const isToFieldValid = (addresses: string | string[]): boolean => { // Every address must be an email to be valid. return mails.every((addr) => validator.isEmail(addr)) } + +export const generateSmsVerificationDisabledHtml = ( + htmlData: SmsVerificationDisabledData, +): ResultAsync => { + const pathToTemplate = `${process.cwd()}/src/app/views/templates/sms-verification-disabled.server.view.html` + return safeRenderFile(pathToTemplate, htmlData) +} diff --git a/src/app/views/templates/sms-verification-disabled.server.view.html b/src/app/views/templates/sms-verification-disabled.server.view.html new file mode 100644 index 0000000000..957f4260da --- /dev/null +++ b/src/app/views/templates/sms-verification-disabled.server.view.html @@ -0,0 +1,38 @@ + + + + +

Dear form admin(s),

+

+ You are receiving this as you have enabled SMS OTP verification on a + Mobile Number field of your form: + '<%= formTitle %>' . +

+

+ SMS OTP verification has been automatically disabled on your form + as you have reached the free tier limit of <%= smsVerificationLimit %> + responses. +

+ +

+ We would have previously notified all form admins upon your account + reaching 2500, 5000 and 7500 responses while free SMS OTP verification was + enabled. +

+

+ If you require SMS OTP verification for more than <%= smsVerificationLimit + %> responses, please + + arrange advance billing with us. + +

+

+ Important: Please refrain from creating multiple forms for the + same use case in order to avoid this limit, as such forms risk being + flagged for abuse and blacklisted. +

+

The FormSG Team

+ + From c92a35f6430c856b7a2630ccafff53d7448624ad Mon Sep 17 00:00:00 2001 From: seaerchin <44049504+seaerchin@users.noreply.github.com> Date: Mon, 14 Jun 2021 11:52:48 +0800 Subject: [PATCH 02/16] feat(sms limit): sms warning for different limits (#2104) * feat(templates): adds new sms verification warning template * feat(mail.service): adds new method to send sms verification warning mail * chore(templates): added colon for proper punctuation for sms verification template * chore(sms-verification-warning): updated wording to reflect limit is on admin level * refactor(mail.service): updated the api for mail service --- .../mail/__tests__/mail.service.spec.ts | 109 ++++++++++++++++++ src/app/services/mail/mail.service.ts | 39 +++++++ src/app/services/mail/mail.types.ts | 7 ++ src/app/services/mail/mail.utils.ts | 8 ++ .../sms-verification-warning.view.html | 29 +++++ 5 files changed, 192 insertions(+) create mode 100644 src/app/views/templates/sms-verification-warning.view.html diff --git a/src/app/services/mail/__tests__/mail.service.spec.ts b/src/app/services/mail/__tests__/mail.service.spec.ts index f331e6a5ce..52629fbb86 100644 --- a/src/app/services/mail/__tests__/mail.service.spec.ts +++ b/src/app/services/mail/__tests__/mail.service.spec.ts @@ -1354,4 +1354,113 @@ describe('mail.service', () => { expect(sendMailSpy).toHaveBeenCalledTimes(0) }) }) + + describe('sendSmsVerificationWarningEmail', () => { + const MOCK_FORM_ID = 'mockFormId' + const MOCK_FORM_TITLE = 'You are all individuals!' + const MOCK_INVALID_EMAIL = 'something wrong@a' + + const MOCK_FORM: IPopulatedForm = { + permissionList: [ + { email: MOCK_VALID_EMAIL }, + { email: MOCK_VALID_EMAIL_2 }, + ], + admin: { + email: MOCK_VALID_EMAIL_3, + }, + title: MOCK_FORM_TITLE, + _id: MOCK_FORM_ID, + } as unknown as IPopulatedForm + + const MOCK_INVALID_EMAIL_FORM: IPopulatedForm = { + permissionList: [], + admin: { + email: MOCK_INVALID_EMAIL, + }, + title: MOCK_FORM_TITLE, + _id: MOCK_FORM_ID, + } as unknown as IPopulatedForm + + const generateExpectedMailOptions = async ( + count: number, + emailRecipients: string | string[], + ) => { + const result = await MailUtils.generateSmsVerificationWarningHtml({ + formTitle: MOCK_FORM_TITLE, + formLink: `${MOCK_APP_URL}/${MOCK_FORM_ID}`, + numAvailable: SMS_VERIFICATION_LIMIT - count, + smsVerificationLimit: SMS_VERIFICATION_LIMIT, + }).map((emailHtml) => { + return { + to: emailRecipients, + from: MOCK_SENDER_STRING, + html: emailHtml, + subject: '[FormSG] SMS Verification - Free Tier Limit Alert', + replyTo: MOCK_SENDER_EMAIL, + bcc: MOCK_SENDER_EMAIL, + } + }) + return result._unsafeUnwrap() + } + + it('should send verified sms warning emails successfully', async () => { + // Arrange + // sendMail should return mocked success response + sendMailSpy.mockResolvedValueOnce('mockedSuccessResponse') + + // Act + const actualResult = await mailService.sendSmsVerificationWarningEmail( + MOCK_FORM, + 1000, + ) + const expectedMailOptions = await generateExpectedMailOptions(1000, [ + MOCK_VALID_EMAIL, + MOCK_VALID_EMAIL_2, + MOCK_VALID_EMAIL_3, + ]) + + // Assert + expect(actualResult._unsafeUnwrap()).toEqual(true) + // Check arguments passed to sendNodeMail + expect(sendMailSpy).toHaveBeenCalledTimes(1) + expect(sendMailSpy).toHaveBeenCalledWith(expectedMailOptions) + }) + + it('should return MailSendError when the provided email is invalid', async () => { + // Act + const actualResult = await mailService.sendSmsVerificationWarningEmail( + MOCK_INVALID_EMAIL_FORM, + 1000, + ) + + // Assert + expect(actualResult).toEqual( + err(new MailSendError('Invalid email error')), + ) + // Check arguments passed to sendNodeMail + expect(sendMailSpy).toHaveBeenCalledTimes(0) + }) + + it('should return MailGenerationError when the html template could not be created', async () => { + // Arrange + jest.spyOn(ejs, 'renderFile').mockRejectedValueOnce('no.') + + // Act + const actualResult = await mailService.sendSmsVerificationWarningEmail( + MOCK_INVALID_EMAIL_FORM, + 1000, + ) + + // Assert + expect(actualResult).toEqual( + err( + new MailGenerationError( + 'Error occurred whilst rendering mail template', + ), + ), + ) + // Check arguments passed to sendNodeMail + expect(sendMailSpy).toHaveBeenCalledTimes(0) + }) + }) }) diff --git a/src/app/services/mail/mail.service.ts b/src/app/services/mail/mail.service.ts index f2be101651..1cd89f77e3 100644 --- a/src/app/services/mail/mail.service.ts +++ b/src/app/services/mail/mail.service.ts @@ -28,6 +28,7 @@ import { SendMailOptions, SendSingleAutoreplyMailArgs, SmsVerificationDisabledData, + SmsVerificationWarningData, } from './mail.types' import { generateAutoreplyHtml, @@ -35,6 +36,7 @@ import { generateBounceNotificationHtml, generateLoginOtpHtml, generateSmsVerificationDisabledHtml, + generateSmsVerificationWarningHtml, generateSubmissionToAdminHtml, generateVerificationOtpHtml, isToFieldValid, @@ -611,6 +613,43 @@ export class MailService { return this.#sendNodeMail(mailOptions, { formId: form._id }) }) } + + /** + * Sends a warning email to the admin and collaborators of the form when their current verified sms counts hits a limit + * @param form The form whose admin and collaborators will be issued a warning + * @param smsVerifications The current total sms verifications for the form + * @returns ok(true) when mail sending is successful + * @returns err(MailGenerationError) when there was an error in generating the html data for the mail + * @returns err(MailSendError) when there was an error in sending the mail + */ + sendSmsVerificationWarningEmail = ( + form: Pick, + smsVerifications: number, + ): ResultAsync => { + const htmlData: SmsVerificationWarningData = { + formTitle: form.title, + formLink: `${this.#appUrl}/${form._id}`, + numAvailable: SMS_VERIFICATION_LIMIT - smsVerifications, + smsVerificationLimit: SMS_VERIFICATION_LIMIT, + } + + return generateSmsVerificationWarningHtml(htmlData).andThen((mailHtml) => { + const emailRecipients = form.permissionList + .map(({ email }) => email) + .concat(form.admin.email) + + const mailOptions: MailOptions = { + to: emailRecipients, + from: this.#senderFromString, + html: mailHtml, + subject: '[FormSG] SMS Verification - Free Tier Limit Alert', + replyTo: this.#senderMail, + bcc: this.#senderMail, + } + + return this.#sendNodeMail(mailOptions, { formId: form._id }) + }) + } } export default new MailService() diff --git a/src/app/services/mail/mail.types.ts b/src/app/services/mail/mail.types.ts index 6100de4ae3..c5f49aad16 100644 --- a/src/app/services/mail/mail.types.ts +++ b/src/app/services/mail/mail.types.ts @@ -92,3 +92,10 @@ export type SmsVerificationDisabledData = { formTitle: string smsVerificationLimit: number } + +export type SmsVerificationWarningData = { + formTitle: string + formLink: string + numAvailable: number + smsVerificationLimit: number +} diff --git a/src/app/services/mail/mail.utils.ts b/src/app/services/mail/mail.utils.ts index 148ac5a65e..d187ff1ae5 100644 --- a/src/app/services/mail/mail.utils.ts +++ b/src/app/services/mail/mail.utils.ts @@ -15,6 +15,7 @@ import { AutoreplySummaryRenderData, BounceNotificationHtmlData, SmsVerificationDisabledData, + SmsVerificationWarningData, SubmissionToAdminHtmlData, } from './mail.types' @@ -177,3 +178,10 @@ export const generateSmsVerificationDisabledHtml = ( const pathToTemplate = `${process.cwd()}/src/app/views/templates/sms-verification-disabled.server.view.html` return safeRenderFile(pathToTemplate, htmlData) } + +export const generateSmsVerificationWarningHtml = ( + htmlData: SmsVerificationWarningData, +): ResultAsync => { + const pathToTemplate = `${process.cwd()}/src/app/views/templates/sms-verification-warning.view.html` + return safeRenderFile(pathToTemplate, htmlData) +} diff --git a/src/app/views/templates/sms-verification-warning.view.html b/src/app/views/templates/sms-verification-warning.view.html new file mode 100644 index 0000000000..65bb81deea --- /dev/null +++ b/src/app/views/templates/sms-verification-warning.view.html @@ -0,0 +1,29 @@ + + + + +

Dear form admin(s),

+

+ You are receiving this as you have enabled SMS OTP verification on a + Mobile Number field of your form: + '<%= formTitle %>' . +

+

+ You can receive <%= numAvailable %> more responses until free SMS + OTP verification is automatically disabled. +

+

+ If you require SMS OTP verification for more than 10,000 responses, please + arrange advance billing with us. + +

+

+ Important: Please refrain from creating multiple forms for the + same use case in order to avoid this limit, as such forms risk being + flagged for abuse and blacklisted. +

+

The FormSG Team

+ + From 77aa15569820f6d38a3c7f7c072bf15fb426c6a2 Mon Sep 17 00:00:00 2001 From: seaerchin Date: Mon, 28 Jun 2021 15:28:13 +0800 Subject: [PATCH 03/16] feat(verified-sms): disabling sms verifications (#2262) feat(sms_count.server.model): adds new isOnboardedAcc key and scripts to add/delete the key chore(sms_count.server.model): remove unrelated code changes chore(scripts): added extra checks for verifiction counts style(scripts): updated whitespace for formatting feat(form.server.model): add new static method for disabling sms verifications for an admin feat(admin-form.service): adds new service method to disable sms verifications for a certain user fix(form.server.model): disabling sms verifications does not show as an update fix(types): updated return type for disabling sms and added extra db types test(form.server.model): added unit tests for disabling sms on the model scehma test(admin-form.service.spec): added tests for disabling sms on admin service chore(form.server.model): addressed PR comments refactor(sms.config): shifts limiting to be on env var refactor(sms.config): reworked to use env-vars --- .../create-isOnboardedAcc.js | 61 +++++++++ .../delete-isOnboardedAcc.js | 30 +++++ src/app/loaders/express/locals.ts | 4 + .../__tests__/form.server.model.spec.ts | 127 +++++++++++++++++- src/app/models/form.server.model.ts | 20 +++ .../__tests__/admin-form.service.spec.ts | 31 +++++ .../form/admin-form/admin-form.service.ts | 25 ++++ .../verification/verification.constants.ts | 1 - .../mail/__tests__/mail.service.spec.ts | 8 +- src/app/services/mail/mail.service.ts | 8 +- src/types/database.ts | 8 ++ src/types/form.ts | 2 +- 12 files changed, 314 insertions(+), 11 deletions(-) create mode 100644 scripts/20210628_create-isOnboardedAcc/create-isOnboardedAcc.js create mode 100644 scripts/20210628_create-isOnboardedAcc/delete-isOnboardedAcc.js delete mode 100644 src/app/modules/verification/verification.constants.ts diff --git a/scripts/20210628_create-isOnboardedAcc/create-isOnboardedAcc.js b/scripts/20210628_create-isOnboardedAcc/create-isOnboardedAcc.js new file mode 100644 index 0000000000..e501166324 --- /dev/null +++ b/scripts/20210628_create-isOnboardedAcc/create-isOnboardedAcc.js @@ -0,0 +1,61 @@ +/* eslint-disable */ + +/* +This script creates a new key, isOnboardedAccount, on the smscounts db which indexes the msgSrvcId variable +*/ + +let formTwilioId = 'insert the form twilio id here' + +// == PRE-UPDATE CHECKS == +// Count the number of verifications (A) +db.getCollection('smscounts').count({ smsType: 'VERIFICATION' }) + +// Count of forms using our twilio acc (B) +db.getCollection('smscounts').count({ + smsType: 'VERIFICATION', +msgSrvcSid: {$eq: formTwilioId}, +}) + +// Count of forms using their own twilio acc (C) +// A === B + C +db.getCollection('smscounts').count({ + smsType: 'VERIFICATION', + msgSrvcSid: { $ne: formTwilioId }, +}) + + +// == UPDATE == +// Update verifications which have message service id equal to form twilio id +db.getCollection('smscounts').updateMany( + { smsType: 'VERIFICATION', msgSrvcSid: { $eq: formTwilioId } }, + { + $set: { + isOnboardedAccount: false, + }, + } +) + +// Update verifications whose message service id is not equal to form twilio id +db.getCollection('smscounts').updateMany( + { smsType: 'VERIFICATION', msgSrvcSid: { $ne: formTwilioId } }, + { + $set: { + isOnboardedAccount: true, + }, + } +) + +// == POST-UPDATE CHECKS == +// Check number of verifications updated +// Sum of these two should be equal to the initial count +// Count of forms using our twilio acc +db.getCollection('smscounts').count({ + smsType: 'VERIFICATION', + msgSrvcSid: { $eq: formTwilioId }, +}) + +// Count of forms using their own twilio acc +db.getCollection('smscounts').count({ + smsType: 'VERIFICATION', + msgSrvcSid: { $ne: formTwilioId }, +}) diff --git a/scripts/20210628_create-isOnboardedAcc/delete-isOnboardedAcc.js b/scripts/20210628_create-isOnboardedAcc/delete-isOnboardedAcc.js new file mode 100644 index 0000000000..9b2492ca01 --- /dev/null +++ b/scripts/20210628_create-isOnboardedAcc/delete-isOnboardedAcc.js @@ -0,0 +1,30 @@ +/* eslint-disable */ + +/* +This script creates a new key, isOnboardedAccount, on the smscounts db which indexes the msgSrvcId variable +*/ + +// == PRE-UPDATE CHECKS == +// Count the number of verifications we have to update +db.getCollection('smscounts').count({ smsType: 'VERIFICATION' }) + +// == UPDATE == +// Update verified smses +db.getCollection('smscounts').updateMany( + { smsType: 'VERIFICATION' }, + { + $unset: { + isOnboardedAccount: false, + }, + } +) + +// == POST-UPDATE CHECKS == +// Check number of verifications updated +// SHOULD BE 0 +db.getCollection('smscounts').count({ + smsType: 'VERIFICATION', + isOnboardedAccount: { + $exists: true + } +}) \ No newline at end of file diff --git a/src/app/loaders/express/locals.ts b/src/app/loaders/express/locals.ts index f3576b95eb..8eb82ecab6 100644 --- a/src/app/loaders/express/locals.ts +++ b/src/app/loaders/express/locals.ts @@ -4,6 +4,7 @@ import config from '../../config/config' import { captchaConfig } from '../../config/features/captcha.config' import { googleAnalyticsConfig } from '../../config/features/google-analytics.config' import { sentryConfig } from '../../config/features/sentry.config' +import { smsConfig } from '../../config/features/sms.config' import { spcpMyInfoConfig } from '../../config/features/spcp-myinfo.config' // Construct js with environment variables needed by frontend @@ -22,6 +23,7 @@ const frontendVars = { GATrackingID: googleAnalyticsConfig.GATrackingID, spcpCookieDomain: spcpMyInfoConfig.spcpCookieDomain, // Cookie domain used for removing spcp cookies oldSpcpCookieDomain: spcpMyInfoConfig.oldSpcpCookieDomain, // Old cookie domain used for backward compatibility. TODO (#2329): Delete env var + smsVerificationLimit: smsConfig.smsVerificationLimit, } const environment = ejs.render( ` @@ -47,6 +49,8 @@ const environment = ejs.render( var spcpCookieDomain = "<%= spcpCookieDomain%>" // Old SPCP Cookie var oldSpcpCookieDomain = "<%= oldSpcpCookieDomain%>" + // Sms verification limit + var smsVerificationLimit = "<%= smsVerificationLimit%>" `, frontendVars, ) diff --git a/src/app/models/__tests__/form.server.model.spec.ts b/src/app/models/__tests__/form.server.model.spec.ts index 19ad0a4755..013c490938 100644 --- a/src/app/models/__tests__/form.server.model.spec.ts +++ b/src/app/models/__tests__/form.server.model.spec.ts @@ -1,6 +1,6 @@ /* eslint-disable @typescript-eslint/ban-ts-comment */ import { ObjectId } from 'bson-ext' -import { cloneDeep, map, merge, omit, orderBy, pick } from 'lodash' +import { cloneDeep, map, merge, omit, orderBy, pick, range } from 'lodash' import mongoose, { Types } from 'mongoose' import { EMAIL_PUBLIC_FORM_FIELDS, @@ -1758,6 +1758,131 @@ describe('Form Model', () => { await expect(Form.countDocuments()).resolves.toEqual(0) }) }) + + describe('disableSmsVerificationsForUser', () => { + it('should disable sms verifications for all forms belonging to a user successfully', async () => { + // Arrange + const mockFormPromises = range(3).map(() => { + return Form.create({ + admin: populatedAdmin._id, + responseMode: ResponseMode.Email, + title: 'mock mobile form', + emails: [populatedAdmin.email], + form_fields: [ + generateDefaultField(BasicField.Mobile, { isVerifiable: true }), + ], + }) + }) + await Promise.all(mockFormPromises) + + // Act + await Form.disableSmsVerificationsForUser(populatedAdmin._id) + + // Assert + // Find all forms that match admin id + const updatedForms = await Form.find({ admin: populatedAdmin._id }) + updatedForms.map(({ form_fields }) => + form_fields!.map((field) => { + expect(field.isVerifiable).toBe(false) + }), + ) + }) + + it('should not disable non mobile fields for a user', async () => { + // Arrange + const mockFormPromises = range(3).map(() => { + return Form.create({ + admin: populatedAdmin._id, + responseMode: ResponseMode.Email, + title: 'mock email form', + emails: [populatedAdmin.email], + form_fields: [ + generateDefaultField(BasicField.Email, { isVerifiable: true }), + ], + }) + }) + await Promise.all(mockFormPromises) + + // Act + await Form.disableSmsVerificationsForUser(populatedAdmin._id) + + // Assert + // Find all forms that match admin id + const updatedForms = await Form.find({ admin: populatedAdmin._id }) + updatedForms.map(({ form_fields }) => + form_fields!.map((field) => { + expect(field.isVerifiable).toBe(true) + }), + ) + }) + + it('should only disable sms verifications for a particular user', async () => { + // Arrange + const MOCK_USER_ID = new ObjectId() + await dbHandler.insertFormCollectionReqs({ + userId: MOCK_USER_ID, + mailDomain: 'something.com', + }) + await Form.create({ + admin: populatedAdmin._id, + responseMode: ResponseMode.Email, + title: 'mock email form', + emails: [populatedAdmin.email], + form_fields: [ + generateDefaultField(BasicField.Mobile, { isVerifiable: true }), + ], + }) + await Form.create({ + admin: MOCK_USER_ID, + responseMode: ResponseMode.Email, + title: 'mock email form', + emails: [populatedAdmin.email], + form_fields: [ + generateDefaultField(BasicField.Email, { isVerifiable: true }), + ], + }) + + // Act + await Form.disableSmsVerificationsForUser(populatedAdmin._id) + + // Assert + // Find all forms that match admin id + const updatedMobileForm = await Form.find({ admin: populatedAdmin._id }) + expect(updatedMobileForm[0]!.form_fields[0].isVerifiable).toBe(false) + const updatedEmailForm = await Form.find({ admin: MOCK_USER_ID }) + expect(updatedEmailForm[0]!.form_fields[0].isVerifiable).toBe(true) + }) + + it('should not update when a db error occurs', async () => { + // Arrange + const disableSpy = jest.spyOn(Form, 'disableSmsVerificationsForUser') + disableSpy.mockResolvedValueOnce(new Error('tee hee db crashed')) + const mockFormPromises = range(3).map(() => { + return Form.create({ + admin: populatedAdmin._id, + responseMode: ResponseMode.Email, + title: 'mock mobile form', + emails: [populatedAdmin.email], + form_fields: [ + generateDefaultField(BasicField.Mobile, { isVerifiable: true }), + ], + }) + }) + await Promise.all(mockFormPromises) + + // Act + await Form.disableSmsVerificationsForUser(populatedAdmin._id) + + // Assert + // Find all forms that match admin id + const updatedForms = await Form.find({ admin: populatedAdmin._id }) + updatedForms.map(({ form_fields }) => + form_fields!.map((field) => { + expect(field.isVerifiable).toBe(true) + }), + ) + }) + }) }) describe('Methods', () => { diff --git a/src/app/models/form.server.model.ts b/src/app/models/form.server.model.ts index 719d91e2b6..55244c3417 100644 --- a/src/app/models/form.server.model.ts +++ b/src/app/models/form.server.model.ts @@ -811,6 +811,26 @@ const compileFormModel = (db: Mongoose): IFormModel => { ).exec() } + FormSchema.statics.disableSmsVerificationsForUser = async function ( + userId: IUserSchema['_id'], + ) { + return this.updateMany( + // Filter the collection so that only specified user is selected + { + admin: userId, + }, + // Next, set the isVerifiable property for each field in form_fields + // Refer here for $[identifier] syntax: https://docs.mongodb.com/manual/reference/operator/update/positional-filtered/ + { $set: { 'form_fields.$[field].isVerifiable': false } }, + { + // Only set if the field has fieldType equal to mobile + arrayFilters: [{ 'field.fieldType': 'mobile' }], + // NOTE: Not updating the timestamp because we should preserve ordering due to user-level modifications + timestamps: false, + }, + ).exec() + } + // Hooks FormSchema.pre('validate', function (next) { // Reject save if form document is too large diff --git a/src/app/modules/form/admin-form/__tests__/admin-form.service.spec.ts b/src/app/modules/form/admin-form/__tests__/admin-form.service.spec.ts index 9aa164a303..3d4459b9fb 100644 --- a/src/app/modules/form/admin-form/__tests__/admin-form.service.spec.ts +++ b/src/app/modules/form/admin-form/__tests__/admin-form.service.spec.ts @@ -74,6 +74,7 @@ import { createPresignedPostUrlForLogos, deleteFormField, deleteFormLogic, + disableSmsVerificationsForUser, duplicateForm, duplicateFormField, editFormFields, @@ -2222,4 +2223,34 @@ describe('admin-form.service', () => { expect(actual._unsafeUnwrapErr()).toEqual(expectedError) }) }) + + describe('disableSmsVerificationsForUser', () => { + it('should return true when the forms are updated successfully', async () => { + // Arrange + const MOCK_ADMIN_ID = new ObjectId().toHexString() + const disableSpy = jest.spyOn(FormModel, 'disableSmsVerificationsForUser') + disableSpy.mockResolvedValueOnce({ n: 0, nModified: 0, ok: 0 }) + + // Act + const expected = await disableSmsVerificationsForUser(MOCK_ADMIN_ID) + + // Assert + expect(disableSpy).toHaveBeenCalledWith(MOCK_ADMIN_ID) + expect(expected._unsafeUnwrap()).toEqual(true) + }) + + it('should return a database error when the operation fails', async () => { + // Arrange + const MOCK_ADMIN_ID = new ObjectId().toHexString() + const disableSpy = jest.spyOn(FormModel, 'disableSmsVerificationsForUser') + disableSpy.mockRejectedValueOnce('whoops') + + // Act + const expected = await disableSmsVerificationsForUser(MOCK_ADMIN_ID) + + // Assert + expect(disableSpy).toHaveBeenCalledWith(MOCK_ADMIN_ID) + expect(expected._unsafeUnwrapErr()).toBeInstanceOf(DatabaseError) + }) + }) }) diff --git a/src/app/modules/form/admin-form/admin-form.service.ts b/src/app/modules/form/admin-form/admin-form.service.ts index 4b0fb80737..b6e0c6c53d 100644 --- a/src/app/modules/form/admin-form/admin-form.service.ts +++ b/src/app/modules/form/admin-form/admin-form.service.ts @@ -1058,3 +1058,28 @@ export const updateStartPage = ( return okAsync(updatedForm.startPage) }) } + +/** + * Disables sms verifications for all forms belonging to the specified user + * @param userId the id of the user whose sms verifications should be disabled + * @returns ok(true) when the forms have been successfully disabled + * @returns err(PossibleDatabaseError) when an error occurred while attempting to disable sms verifications + */ +export const disableSmsVerificationsForUser = ( + userId: string, +): ResultAsync => + ResultAsync.fromPromise( + FormModel.disableSmsVerificationsForUser(userId), + (error) => { + logger.error({ + message: + 'Error occurred when attempting to disable sms verifications for user', + meta: { + action: 'disableSmsVerificationsForUser', + userId, + }, + error, + }) + return transformMongoError(error) + }, + ).map(() => true) diff --git a/src/app/modules/verification/verification.constants.ts b/src/app/modules/verification/verification.constants.ts deleted file mode 100644 index c344320aa6..0000000000 --- a/src/app/modules/verification/verification.constants.ts +++ /dev/null @@ -1 +0,0 @@ -export const SMS_VERIFICATION_LIMIT = 10000 diff --git a/src/app/services/mail/__tests__/mail.service.spec.ts b/src/app/services/mail/__tests__/mail.service.spec.ts index 52629fbb86..34a8fadca0 100644 --- a/src/app/services/mail/__tests__/mail.service.spec.ts +++ b/src/app/services/mail/__tests__/mail.service.spec.ts @@ -18,7 +18,7 @@ import * as MailUtils from 'src/app/services/mail/mail.utils' import { HASH_EXPIRE_AFTER_SECONDS } from 'src/shared/util/verification' import { BounceType, IPopulatedForm, ISubmissionSchema } from 'src/types' -import { SMS_VERIFICATION_LIMIT } from '../../../modules/verification/verification.constants' +import { smsConfig } from '../../../config/features/sms.config' const MOCK_VALID_EMAIL = 'to@example.com' const MOCK_VALID_EMAIL_2 = 'to2@example.com' @@ -1283,7 +1283,7 @@ describe('mail.service', () => { const result = await MailUtils.generateSmsVerificationDisabledHtml({ formTitle: MOCK_FORM_TITLE, formLink: `${MOCK_APP_URL}/${MOCK_FORM_ID}`, - smsVerificationLimit: SMS_VERIFICATION_LIMIT, + smsVerificationLimit: smsConfig.smsVerificationLimit, }).map((emailHtml) => { return { to: emailRecipients, @@ -1388,8 +1388,8 @@ describe('mail.service', () => { const result = await MailUtils.generateSmsVerificationWarningHtml({ formTitle: MOCK_FORM_TITLE, formLink: `${MOCK_APP_URL}/${MOCK_FORM_ID}`, - numAvailable: SMS_VERIFICATION_LIMIT - count, - smsVerificationLimit: SMS_VERIFICATION_LIMIT, + numAvailable: smsConfig.smsVerificationLimit - count, + smsVerificationLimit: smsConfig.smsVerificationLimit, }).map((emailHtml) => { return { to: emailRecipients, diff --git a/src/app/services/mail/mail.service.ts b/src/app/services/mail/mail.service.ts index 1cd89f77e3..686dd9d915 100644 --- a/src/app/services/mail/mail.service.ts +++ b/src/app/services/mail/mail.service.ts @@ -14,8 +14,8 @@ import { ISubmissionSchema, } from '../../../types' import config from '../../config/config' +import { smsConfig } from '../../config/features/sms.config' import { createLoggerWithLabel } from '../../config/logger' -import { SMS_VERIFICATION_LIMIT } from '../../modules/verification/verification.constants' import { EMAIL_HEADERS, EmailType } from './mail.constants' import { MailGenerationError, MailSendError } from './mail.errors' @@ -593,7 +593,7 @@ export class MailService { const htmlData: SmsVerificationDisabledData = { formTitle: form.title, formLink: `${this.#appUrl}/${form._id}`, - smsVerificationLimit: SMS_VERIFICATION_LIMIT, + smsVerificationLimit: smsConfig.smsVerificationLimit, } return generateSmsVerificationDisabledHtml(htmlData).andThen((mailHtml) => { @@ -629,8 +629,8 @@ export class MailService { const htmlData: SmsVerificationWarningData = { formTitle: form.title, formLink: `${this.#appUrl}/${form._id}`, - numAvailable: SMS_VERIFICATION_LIMIT - smsVerifications, - smsVerificationLimit: SMS_VERIFICATION_LIMIT, + numAvailable: smsConfig.smsVerificationLimit - smsVerifications, + smsVerificationLimit: smsConfig.smsVerificationLimit, } return generateSmsVerificationWarningHtml(htmlData).andThen((mailHtml) => { diff --git a/src/types/database.ts b/src/types/database.ts index eac05ab714..f30b9a593e 100644 --- a/src/types/database.ts +++ b/src/types/database.ts @@ -3,3 +3,11 @@ export interface PublicView { getPublicView(): T } + +// The returned object from updateMany +// Refer here: https://mongoosejs.com/docs/api/model.html#model_Model.updateMany +export interface UpdateManyMeta { + n: number + nModified: number + ok: number +} diff --git a/src/types/form.ts b/src/types/form.ts index a085bbad94..520795ec6e 100644 --- a/src/types/form.ts +++ b/src/types/form.ts @@ -28,7 +28,7 @@ import { } from '../../shared/types/form/form' import { OverrideProps } from '../app/modules/form/admin-form/admin-form.types' -import { PublicView } from './database' +import { PublicView, UpdateManyMeta } from './database' import { FormField, FormFieldSchema, From 998f23ae0e66726680a03e78d7dc6a3a1b04002a Mon Sep 17 00:00:00 2001 From: seaerchin <44049504+seaerchin@users.noreply.github.com> Date: Wed, 14 Jul 2021 13:13:39 +0800 Subject: [PATCH 04/16] feat(sms-limiting): admin facing frontend (#2280) * feat(configure-mobile.client.directive): updated modal to use new messages with hard-coded data * refactor(verification.constants): shifted sms_verification_limit to shared/util/verification * feat(configure-mobile.client): added frontend ui for toggling on/off * feat(configure-mobile.client.view): added lock and disabled state when limit exceeded * refactor(configure-mobile.client.view): changed text message to be error themed * refactor(edit-form.css): added inline-padding for error message display * fix(configure-mobile.client.view): updated form twilio account link * feat(configure-mobile.client.directive): added loading state for toggle * refactor(configure-mobile.client.view): update wording for error message * fix(configure-mobile.client.view.html): added ng-show so that error only shows when limit exceeded * feat(public/services): adds new smsservice * feat(configure-mobile.client.directive): connected sms counts to fetch from backend * refactor(pop-up-modal): adds variable to check if we should display red button \ * test(smsservice): adds tests for sms service * refactor(configure-mobile.client): adds error reporting and handling on FE * refactor(smsservice): renamed SmsService to AdminMetaService * refactor(adminmetaservice): renamed method for greater clarity * chore(configure-mobile.client): addressed PR comments * refactor(configure-mobile.client): uses env var instead of using constants * refactor(mail.service): changes to use env var instead of const * refactor(adminmetaservice): updated definitions to fit BE * refactor(configure-mobile.client): changed condition to be same as BE * refactor(configure-mobile.client.directive): extracts formatting to own function --- .../pop-up-modal.client.controller.js | 1 + .../modules/forms/admin/css/edit-form.css | 11 ++ .../configure-mobile.client.view.html | 33 +++++- .../configure-mobile.client.directive.js | 108 +++++++++++++++--- .../admin/views/edit-fields.client.modal.html | 1 + .../admin/views/pop-up.client.modal.html | 3 +- .../base/componentViews/verifiable-field.html | 5 +- src/public/services/AdminMetaService.ts | 16 +++ .../__tests__/AdminMetaService.test.ts | 29 +++++ src/public/translations/en-SG/main.json | 1 + src/public/utils/injectedVariables.ts | 2 + src/shared/util/verification.ts | 6 + 12 files changed, 198 insertions(+), 18 deletions(-) create mode 100644 src/public/services/AdminMetaService.ts create mode 100644 src/public/services/__tests__/AdminMetaService.test.ts diff --git a/src/public/modules/forms/admin/controllers/pop-up-modal.client.controller.js b/src/public/modules/forms/admin/controllers/pop-up-modal.client.controller.js index 9c3c33a776..0692194d4d 100644 --- a/src/public/modules/forms/admin/controllers/pop-up-modal.client.controller.js +++ b/src/public/modules/forms/admin/controllers/pop-up-modal.client.controller.js @@ -15,6 +15,7 @@ function PopUpModalController($uibModalInstance, externalScope) { vm.description = externalScope.description vm.confirmButtonText = externalScope.confirmButtonText vm.cancelButtonText = externalScope.cancelButtonText + vm.isImportant = externalScope.isImportant vm.cancel = $uibModalInstance.close vm.confirm = function () { diff --git a/src/public/modules/forms/admin/css/edit-form.css b/src/public/modules/forms/admin/css/edit-form.css index 38f1a362ef..25c1fa0882 100644 --- a/src/public/modules/forms/admin/css/edit-form.css +++ b/src/public/modules/forms/admin/css/edit-form.css @@ -1426,3 +1426,14 @@ a.modal-cancel-btn:hover { .pull-right { cursor: pointer; } + +.inline-padding { + /* margin is set so that the error message appears to be in the same block as the toggle options */ + margin: 0 15px; +} + +/* Spinner */ +.loading-spinner { + display: flex; + justify-content: flex-end; +} diff --git a/src/public/modules/forms/admin/directiveViews/configure-mobile.client.view.html b/src/public/modules/forms/admin/directiveViews/configure-mobile.client.view.html index 44b68cf7fc..989e0d131f 100644 --- a/src/public/modules/forms/admin/directiveViews/configure-mobile.client.view.html +++ b/src/public/modules/forms/admin/directiveViews/configure-mobile.client.view.html @@ -1,6 +1,9 @@
-
+
OTP verification
+
+ +

+
+ + + You have reached the free tier limit for SMS verification. To continue + using SMS verification, + + please arrange for billing with us + +
diff --git a/src/public/modules/forms/admin/directives/configure-mobile.client.directive.js b/src/public/modules/forms/admin/directives/configure-mobile.client.directive.js index ee0bcec6a0..7f2a4b25c3 100644 --- a/src/public/modules/forms/admin/directives/configure-mobile.client.directive.js +++ b/src/public/modules/forms/admin/directives/configure-mobile.client.directive.js @@ -1,4 +1,13 @@ 'use strict' +const { get } = require('lodash') + +const { + ADMIN_VERIFIED_SMS_STATES, +} = require('../../../../../shared/util/verification') + +const AdminMetaService = require('../../../../services/AdminMetaService') + +const { injectedVariables } = require('../../../../utils/injectedVariables') angular .module('forms') @@ -10,24 +19,89 @@ function configureMobileDirective() { 'modules/forms/admin/directiveViews/configure-mobile.client.view.html', restrict: 'E', scope: { - field: '=', + field: '<', + form: '<', name: '=', characterLimit: '=', + isLoading: '<', }, controller: [ + '$q', '$uibModal', '$scope', '$translate', - function ($uibModal, $scope, $translate) { - // Get support form link from translation json. - $translate('LINKS.SUPPORT_FORM_LINK').then((supportFormLink) => { - $scope.supportFormLink = supportFormLink - }) + 'Toastr', + function ($q, $uibModal, $scope, $translate, Toastr) { + // Get the link for onboarding the form from the translation json + $translate('LINKS.VERIFIED_SMS_SETUP_LINK').then( + (verifiedSmsSetupLink) => { + $scope.verifiedSmsSetupLink = verifiedSmsSetupLink + }, + ) + + // Formats a given string as a number by setting it to US locale. + // Concretely, this adds commas between every thousand. + const formatStringAsNumber = (num) => + Number(num).toLocaleString('en-US') + + // NOTE: This is set on scope as it is used by the UI to determine if the toggle is loading + $scope.isLoading = true + $scope.field.hasRetrievalError = false + + const formattedSmsVerificationLimit = + // Format so that it has commas; conversion is required because it's string initially + formatStringAsNumber(injectedVariables.smsVerificationLimit) + const getAdminVerifiedSmsState = (verifiedSmsCount, msgSrvcId) => { + if (msgSrvcId) { + return ADMIN_VERIFIED_SMS_STATES.hasMessageServiceId + } + if (verifiedSmsCount <= injectedVariables.smsVerificationLimit) { + return ADMIN_VERIFIED_SMS_STATES.belowLimit + } + return ADMIN_VERIFIED_SMS_STATES.limitExceeded + } + + $q.when( + AdminMetaService.getFreeSmsCountsUsedByFormAdmin($scope.form._id), + ) + .then((smsCounts) => { + $scope.verifiedSmsCount = smsCounts + $scope.adminVerifiedSmsState = getAdminVerifiedSmsState( + smsCounts, + $scope.form.msgSrvcName, + ) + // NOTE: This links into the verifiable field component and hence, is used by both email and mobile + $scope.field.hasAdminExceededSmsLimit = + $scope.adminVerifiedSmsState === + ADMIN_VERIFIED_SMS_STATES.limitExceeded + }) + .catch((error) => { + $scope.field.hasRetrievalError = true + Toastr.error( + get( + error, + 'response.data.message', + 'Sorry, an error occurred. Please refresh the page to toggle OTP verification.', + ), + ) + }) + .finally(() => ($scope.isLoading = false)) + + // Only open if the admin has sms counts below the limit. + // If the admin has counts above limit without a message id, the toggle should be disabled anyway. + // Otherwise, if the admin has a message id, just enable it without the modal $scope.openVerifiedSMSModal = function () { const isTogglingOnVerifiedSms = !$scope.field.isVerifiable - $scope.verifiedSMSModal = + const isAdminBelowLimit = + $scope.adminVerifiedSmsState === + ADMIN_VERIFIED_SMS_STATES.belowLimit + const shouldShowModal = isTogglingOnVerifiedSms && + isAdminBelowLimit && + !$scope.field.hasRetrievalError + $scope.verifiedSMSModal = + shouldShowModal && $uibModal.open({ animation: true, backdrop: 'static', @@ -39,14 +113,22 @@ function configureMobileDirective() { resolve: { externalScope: function () { return { - title: 'Verified SMS charges', - confirmButtonText: 'OK, Noted', + title: `OTP verification will be disabled at ${formattedSmsVerificationLimit} responses`, + confirmButtonText: 'Accept', description: ` - Under 10,000 form responses: Free verified SMS -

- Above 10,000 form responses: ~US$0.0395 per SMS - contact us - for billing. Forms exceeding the free tier without billing will be deactivated. + We provide SMS OTP verification for free up to ${formattedSmsVerificationLimit} responses. OTP verification will be automatically disabled when your account reaches ${formattedSmsVerificationLimit} responses. +

+ If you require OTP verification for more than ${formattedSmsVerificationLimit} responses, + please arrange advance billing with us. + +

+ Current response count: ${formatStringAsNumber( + $scope.verifiedSmsCount, + )}/${formattedSmsVerificationLimit} `, + isImportant: true, } }, }, diff --git a/src/public/modules/forms/admin/views/edit-fields.client.modal.html b/src/public/modules/forms/admin/views/edit-fields.client.modal.html index 98f1c99808..6dfd863430 100644 --- a/src/public/modules/forms/admin/views/edit-fields.client.modal.html +++ b/src/public/modules/forms/admin/views/edit-fields.client.modal.html @@ -849,6 +849,7 @@