diff --git a/src/app/core/sms-poc/sms-poc.service.ts b/src/app/core/sms-poc/sms-poc.service.ts index c0f101ed1..914cbe69f 100644 --- a/src/app/core/sms-poc/sms-poc.service.ts +++ b/src/app/core/sms-poc/sms-poc.service.ts @@ -2,7 +2,11 @@ import { Injectable } from '@angular/core' import { HttpClient } from '@angular/common/http' import { catchError } from 'rxjs/operators' import { ErrorHandlerService } from '../error-handler/error-handler.service' -import { SmsPocRequest, SmsPocResponse } from 'src/app/types/sms-poc.endpoint' +import { + SmsPocRequest, + SmsPocResponse, + SmsVerificationCheckRequest, +} from 'src/app/types/sms-poc.endpoint' @Injectable({ providedIn: 'root', @@ -24,4 +28,16 @@ export class SmsPocService { ) .pipe(catchError((error) => this._errorHandler.handleError(error))) } + + verify(data: SmsVerificationCheckRequest) { + return this._http + .post( + runtimeEnvironment.API_WEB + 'sms-poc/verify.json', + data, + { + withCredentials: true, + } + ) + .pipe(catchError((error) => this._errorHandler.handleError(error))) + } } diff --git a/src/app/sms-poc/pages/sms-poc/sms-poc.component.html b/src/app/sms-poc/pages/sms-poc/sms-poc.component.html index b986d119b..a4f69c74f 100644 --- a/src/app/sms-poc/pages/sms-poc/sms-poc.component.html +++ b/src/app/sms-poc/pages/sms-poc/sms-poc.component.html @@ -1,41 +1,36 @@ -

SMS POC

+

+ Two-step verification by SMS +

- Send a test SMS. Choose AWS or Twilio for each send. + Add a phone number to your ORCID account. We'll send a one-time + verification code by SMS to confirm the number.

- -
- - This POC currently runs in SANDBOX mode. If you want to test actual SMS - delivery to your phone, please reach out to Leo on Slack to add your - phone number to our AWS SNS ORCID Friend Sandbox and the Twilio - accounts. - -
-
- + @if (step === 'send') {
+ @if (showProviderSelector) {
- Provider + Provider (internal testing only) - AWS SNS - Twilio + AWS End User Messaging + Twilio Verify
+ }
SMS POC }
+ + + @if (backendError) { + +
{{ backendError }}
+
+ } @if (loading) { + + } + + + + } @else { +
+

+ Enter the 6-digit code we sent to + {{ verifiedPhoneNumber }}. +

+ - Message - - {{ message?.value?.length || 0 }}/1600 - @if (message?.hasError('required') && message?.touched) { - Message is required - } @if (message?.hasError('maxlength') && message?.touched) { - Message must be 1600 characters or fewer + formControlName="code" + inputmode="numeric" + autocomplete="one-time-code" + maxlength="8" + /> + @if (code?.hasError('required') && code?.touched) { + Verification code is required } @@ -72,14 +122,17 @@

SMS POC

{{ backendError }}
- } @if (response?.success) { + } @if (verifyResponse?.verified) {
- SMS sent. - - {{ response.provider }}: - {{ response.providerMessageId || response.status }} - + Phone number verified. +
+
+ } @else if (verifyResponse && verifyResponse.success && + !verifyResponse.verified) { + +
+ That code is not correct. Please try again.
} @if (loading) { @@ -93,7 +146,12 @@

SMS POC

[disabled]="loading" [ngClass]="{ 'button-loading': loading }" > - Send SMS + Verify code + + +
+ } diff --git a/src/app/sms-poc/pages/sms-poc/sms-poc.component.scss b/src/app/sms-poc/pages/sms-poc/sms-poc.component.scss index e3efd8ce9..575ee87f3 100644 --- a/src/app/sms-poc/pages/sms-poc/sms-poc.component.scss +++ b/src/app/sms-poc/pages/sms-poc/sms-poc.component.scss @@ -88,6 +88,25 @@ intl-tel-input { padding: 0.35rem 0.75rem; } +.consent-field { + padding: 0.75rem; + border-radius: 10px; + border: 1px solid rgba(15, 23, 42, 0.12); + background: #fafcf5; +} + +// Keep the checkbox aligned to the top of a multi-line consent label. +:host ::ng-deep .consent-field .mat-mdc-checkbox .mdc-form-field { + align-items: flex-start; +} + +.consent-text { + display: inline-block; + white-space: normal; + line-height: 1.5; + color: #334155; +} + @media (max-width: 640px) { :host { align-items: flex-start; diff --git a/src/app/sms-poc/pages/sms-poc/sms-poc.component.spec.ts b/src/app/sms-poc/pages/sms-poc/sms-poc.component.spec.ts index f2b7ef043..329eb129c 100644 --- a/src/app/sms-poc/pages/sms-poc/sms-poc.component.spec.ts +++ b/src/app/sms-poc/pages/sms-poc/sms-poc.component.spec.ts @@ -1,7 +1,9 @@ import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core' import { ComponentFixture, TestBed } from '@angular/core/testing' import { ReactiveFormsModule } from '@angular/forms' +import { MatCheckboxModule } from '@angular/material/checkbox' import { MatRadioModule } from '@angular/material/radio' +import { ActivatedRoute } from '@angular/router' import IntlTelInput from '@intl-tel-input/angular' import { of } from 'rxjs' @@ -14,12 +16,23 @@ describe('SmsPocComponent', () => { let smsPocService: jasmine.SpyObj beforeEach(() => { - smsPocService = jasmine.createSpyObj('SmsPocService', ['send']) + smsPocService = jasmine.createSpyObj('SmsPocService', ['send', 'verify']) TestBed.configureTestingModule({ - imports: [IntlTelInput, MatRadioModule, ReactiveFormsModule], + imports: [ + IntlTelInput, + MatCheckboxModule, + MatRadioModule, + ReactiveFormsModule, + ], declarations: [SmsPocComponent], - providers: [{ provide: SmsPocService, useValue: smsPocService }], + providers: [ + { provide: SmsPocService, useValue: smsPocService }, + { + provide: ActivatedRoute, + useValue: { snapshot: { queryParamMap: { get: () => null } } }, + }, + ], schemas: [CUSTOM_ELEMENTS_SCHEMA], }).compileComponents() }) @@ -30,9 +43,19 @@ describe('SmsPocComponent', () => { fixture.detectChanges() }) - it('does not call the backend when the form is invalid', () => { - component.smsForm.controls['phoneNumber'].setValue('') - component.smsForm.controls['message'].setValue('ORCID SMS POC test') + function sendSuccess(provider = 'aws') { + return of({ + success: true, + provider, + providerMessageId: 'message-id', + normalizedPhoneNumber: '+50688888888', + status: 'SENT', + }) + } + + it('does not call the backend without consent', () => { + component.smsForm.controls['phoneNumber'].setValue('+50688888888') + component.smsForm.controls['consent'].setValue(false) component.onSubmit() @@ -40,49 +63,59 @@ describe('SmsPocComponent', () => { expect(component.smsForm.touched).toBeTrue() }) - it('calls the backend when the form is valid', () => { - smsPocService.send.and.returnValue( - of({ - success: true, - provider: 'aws', - providerMessageId: 'message-id', - normalizedPhoneNumber: '+50688888888', - status: 'SENT', - }) - ) + it('sends only phone number and provider when the form is valid', () => { + smsPocService.send.and.returnValue(sendSuccess()) component.smsForm.controls['phoneNumber'].setValue('+50688888888') - component.smsForm.controls['message'].setValue('ORCID SMS POC test') + component.smsForm.controls['consent'].setValue(true) component.onSubmit() expect(smsPocService.send).toHaveBeenCalledWith({ provider: 'aws', phoneNumber: '+50688888888', - message: 'ORCID SMS POC test', + locale: 'en-US', }) - expect(component.response?.success).toBeTrue() + expect(component.step).toBe('verify') + expect(component.verifiedPhoneNumber).toBe('+50688888888') }) it('sends the selected provider to the backend', () => { - smsPocService.send.and.returnValue( + smsPocService.send.and.returnValue(sendSuccess('twilio')) + component.smsForm.controls['provider'].setValue('twilio') + component.smsForm.controls['phoneNumber'].setValue('+50688888888') + component.smsForm.controls['consent'].setValue(true) + + component.onSubmit() + + expect(smsPocService.send).toHaveBeenCalledWith({ + provider: 'twilio', + phoneNumber: '+50688888888', + locale: 'en-US', + }) + }) + + it('verifies the code against the normalized number', () => { + smsPocService.send.and.returnValue(sendSuccess()) + smsPocService.verify.and.returnValue( of({ success: true, - provider: 'twilio', - providerMessageId: 'message-id', + provider: 'aws', normalizedPhoneNumber: '+50688888888', - status: 'SENT', + status: 'VERIFIED', + verified: true, }) ) - component.smsForm.controls['provider'].setValue('twilio') component.smsForm.controls['phoneNumber'].setValue('+50688888888') - component.smsForm.controls['message'].setValue('ORCID SMS POC test') - + component.smsForm.controls['consent'].setValue(true) component.onSubmit() - expect(smsPocService.send).toHaveBeenCalledWith({ - provider: 'twilio', + component.verifyForm.controls['code'].setValue('123456') + component.onVerify() + + expect(smsPocService.verify).toHaveBeenCalledWith({ phoneNumber: '+50688888888', - message: 'ORCID SMS POC test', + code: '123456', }) + expect(component.verifyResponse?.verified).toBeTrue() }) }) diff --git a/src/app/sms-poc/pages/sms-poc/sms-poc.component.ts b/src/app/sms-poc/pages/sms-poc/sms-poc.component.ts index 4018b7c7c..cf023ee22 100644 --- a/src/app/sms-poc/pages/sms-poc/sms-poc.component.ts +++ b/src/app/sms-poc/pages/sms-poc/sms-poc.component.ts @@ -1,5 +1,6 @@ -import { Component, OnInit } from '@angular/core' +import { Component, Inject, LOCALE_ID, OnInit } from '@angular/core' import { FormControl, FormGroup, Validators } from '@angular/forms' +import { ActivatedRoute } from '@angular/router' import { SmsPocService } from 'src/app/core/sms-poc/sms-poc.service' import { SmsPocResponse } from 'src/app/types/sms-poc.endpoint' @@ -11,22 +12,35 @@ import { SmsPocResponse } from 'src/app/types/sms-poc.endpoint' }) export class SmsPocComponent implements OnInit { smsForm: FormGroup = new FormGroup({}) + verifyForm: FormGroup = new FormGroup({}) + step: 'send' | 'verify' = 'send' loading = false response: SmsPocResponse | null = null + verifyResponse: SmsPocResponse | null = null + verifiedPhoneNumber: string | null = null backendError: string | null = null + // The provider (AWS/Twilio) selector is an internal testing control; hidden by + // default so the opt-in view stays provider-agnostic. Reveal with ?debug=true. + showProviderSelector = false loadUtils = () => import('intl-tel-input/utils') - constructor(private _smsPocService: SmsPocService) {} + constructor( + private _smsPocService: SmsPocService, + private _route: ActivatedRoute, + @Inject(LOCALE_ID) private _locale: string + ) {} ngOnInit() { + this.showProviderSelector = + this._route.snapshot.queryParamMap.get('debug') === 'true' this.smsForm = new FormGroup({ provider: new FormControl<'aws' | 'twilio'>('aws', [Validators.required]), phoneNumber: new FormControl('', [Validators.required]), - message: new FormControl('ORCID SMS POC test', [ - Validators.required, - Validators.maxLength(1600), - ]), + consent: new FormControl(false, [Validators.requiredTrue]), + }) + this.verifyForm = new FormGroup({ + code: new FormControl('', [Validators.required]), }) } @@ -34,8 +48,12 @@ export class SmsPocComponent implements OnInit { return this.smsForm.get('phoneNumber') } - get message() { - return this.smsForm.get('message') + get consent() { + return this.smsForm.get('consent') + } + + get code() { + return this.verifyForm.get('code') } get invalidPhoneMessage() { @@ -61,20 +79,69 @@ export class SmsPocComponent implements OnInit { return } + const { provider, phoneNumber } = this.smsForm.getRawValue() this.loading = true - this._smsPocService.send(this.smsForm.getRawValue()).subscribe({ + this._smsPocService + .send({ provider, phoneNumber, locale: this._locale }) + .subscribe({ next: (response) => { this.loading = false this.response = response - if (!response.success) { + if (response.success) { + this.verifiedPhoneNumber = + response.normalizedPhoneNumber || phoneNumber + this.step = 'verify' + } else { this.backendError = response.errorMessage || response.errorCode || null } }, error: () => { this.loading = false - this.backendError = 'SMS send failed' + this.backendError = 'Verification code could not be sent' }, }) } + + onVerify() { + this.verifyResponse = null + this.backendError = null + + if (this.verifyForm.invalid || !this.verifiedPhoneNumber) { + this.verifyForm.markAllAsTouched() + this.verifyForm.markAllAsDirty() + return + } + + this.loading = true + this._smsPocService + .verify({ + phoneNumber: this.verifiedPhoneNumber, + code: this.code?.value, + }) + .subscribe({ + next: (response) => { + this.loading = false + this.verifyResponse = response + if (!response.success) { + this.backendError = + response.errorMessage || response.errorCode || null + } + }, + error: () => { + this.loading = false + this.backendError = 'Verification failed' + }, + }) + } + + startOver() { + this.step = 'send' + this.response = null + this.verifyResponse = null + this.backendError = null + this.verifiedPhoneNumber = null + this.verifyForm.reset({ code: '' }) + this.smsForm.get('consent')?.reset(false) + } } diff --git a/src/app/sms-poc/sms-poc.module.ts b/src/app/sms-poc/sms-poc.module.ts index 6ef236db4..2aa3acf75 100644 --- a/src/app/sms-poc/sms-poc.module.ts +++ b/src/app/sms-poc/sms-poc.module.ts @@ -3,6 +3,7 @@ import { CommonModule } from '@angular/common' import { ReactiveFormsModule } from '@angular/forms' import { MatButtonModule } from '@angular/material/button' import { MatCardModule } from '@angular/material/card' +import { MatCheckboxModule } from '@angular/material/checkbox' import { MatFormFieldModule } from '@angular/material/form-field' import { MatInputModule } from '@angular/material/input' import { MatProgressBarModule } from '@angular/material/progress-bar' @@ -21,6 +22,7 @@ import { SmsPocComponent } from './pages/sms-poc/sms-poc.component' IntlTelInput, MatButtonModule, MatCardModule, + MatCheckboxModule, MatFormFieldModule, MatInputModule, MatProgressBarModule, diff --git a/src/app/types/sms-poc.endpoint.ts b/src/app/types/sms-poc.endpoint.ts index c40cc4ffe..58f68be7e 100644 --- a/src/app/types/sms-poc.endpoint.ts +++ b/src/app/types/sms-poc.endpoint.ts @@ -2,8 +2,14 @@ export type SmsPocProvider = 'aws' | 'twilio' export interface SmsPocRequest { phoneNumber: string - message: string provider?: SmsPocProvider + /** UI locale (BCP 47); the backend picks a localized SMS template and falls back to English. */ + locale?: string +} + +export interface SmsVerificationCheckRequest { + phoneNumber: string + code: string } export interface SmsPocResponse { @@ -12,6 +18,7 @@ export interface SmsPocResponse { providerMessageId?: string normalizedPhoneNumber?: string status: string + verified?: boolean errorCode?: string errorMessage?: string }