Skip to content
Merged

PD-5769 #2873

Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 17 additions & 1 deletion src/app/core/sms-poc/sms-poc.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -24,4 +28,16 @@ export class SmsPocService {
)
.pipe(catchError((error) => this._errorHandler.handleError(error)))
}

verify(data: SmsVerificationCheckRequest) {
return this._http
.post<SmsPocResponse>(
runtimeEnvironment.API_WEB + 'sms-poc/verify.json',
data,
{
withCredentials: true,
}
)
.pipe(catchError((error) => this._errorHandler.handleError(error)))
}
}
128 changes: 93 additions & 35 deletions src/app/sms-poc/pages/sms-poc/sms-poc.component.html
Original file line number Diff line number Diff line change
@@ -1,41 +1,36 @@
<mat-card class="sms-poc-card max-w-[560px] sm:p-10! p-6!">
<h1 class="orc-font-heading-small font-normal mb-4">SMS POC</h1>
<h1 class="orc-font-heading-small font-normal mb-4">
Two-step verification by SMS
</h1>
<p class="sms-poc-description orc-font-body-small mb-4">
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.
</p>

<app-alert-message type="warning" role="note" class="mb-6 block">
<div content>
<span>
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.
</span>
</div>
</app-alert-message>

@if (step === 'send') {
<form [formGroup]="smsForm" (ngSubmit)="onSubmit()" class="grid gap-4">
@if (showProviderSelector) {
<div class="provider-selection">
<span class="orc-font-small-print leading-4.5 font-bold! block mb-2">
Provider
Provider (internal testing only)
</span>
<mat-radio-group
formControlName="provider"
color="primary"
class="provider-group flex gap-4"
>
<mat-radio-button value="aws"> AWS SNS </mat-radio-button>
<mat-radio-button value="twilio"> Twilio </mat-radio-button>
<mat-radio-button value="aws"> AWS End User Messaging </mat-radio-button>
<mat-radio-button value="twilio"> Twilio Verify </mat-radio-button>
</mat-radio-group>
</div>
}

<div class="phone-field">
<label
class="orc-font-small-print leading-4.5 font-bold! block mb-2"
for="sms-poc-phone"
>
Phone number
Mobile phone number
</label>
<div class="phone-input-shell">
<intl-tel-input
Expand All @@ -52,34 +47,92 @@ <h1 class="orc-font-heading-small font-normal mb-4">SMS POC</h1>
}
</div>

<div class="consent-field">
<mat-checkbox formControlName="consent" color="primary">
<span class="consent-text orc-font-small-print">
By entering my phone number and choosing "Send verification code," I
consent to receive an automated one-time verification code from ORCID
at the number provided. Messages are powered by AWS Notify. Message
and data rates may apply. Message frequency varies. Reply HELP for
help, STOP to cancel. See ORCID's
<a
href="https://info.orcid.org/terms-of-use/"
target="_blank"
rel="noopener"
(click)="$event.stopPropagation()"
>Terms and Conditions</a
>
and
<a
href="https://info.orcid.org/privacy-policy/"
target="_blank"
rel="noopener"
(click)="$event.stopPropagation()"
>Privacy Policy</a
>.
</span>
</mat-checkbox>
@if (consent?.invalid && consent?.touched) {
<mat-error class="orc-font-small-print mt-2 block">
You must agree to receive SMS messages to continue
</mat-error>
}
</div>

@if (backendError) {
<app-alert-message type="warning" role="alert" class="mb-2">
<div content>{{ backendError }}</div>
</app-alert-message>
} @if (loading) {
<mat-progress-bar mode="indeterminate"></mat-progress-bar>
}

<button
mat-raised-button
color="primary"
type="submit"
[disabled]="loading"
[ngClass]="{ 'button-loading': loading }"
>
Send verification code
</button>
</form>
} @else {
<form [formGroup]="verifyForm" (ngSubmit)="onVerify()" class="grid gap-4">
<p class="orc-font-body-small mb-2">
Enter the 6-digit code we sent to
<strong>{{ verifiedPhoneNumber }}</strong>.
</p>

<mat-form-field appearance="outline" [hideRequiredMarker]="true">
<mat-label>Message</mat-label>
<textarea
<mat-label>Verification code</mat-label>
<input
matInput
formControlName="message"
rows="4"
maxlength="1600"
></textarea>
<mat-hint align="end">{{ message?.value?.length || 0 }}/1600</mat-hint>
@if (message?.hasError('required') && message?.touched) {
<mat-error>Message is required</mat-error>
} @if (message?.hasError('maxlength') && message?.touched) {
<mat-error> Message must be 1600 characters or fewer </mat-error>
formControlName="code"
inputmode="numeric"
autocomplete="one-time-code"
maxlength="8"
/>
@if (code?.hasError('required') && code?.touched) {
<mat-error>Verification code is required</mat-error>
}
</mat-form-field>

@if (backendError) {
<app-alert-message type="warning" role="alert" class="mb-2">
<div content>{{ backendError }}</div>
</app-alert-message>
} @if (response?.success) {
} @if (verifyResponse?.verified) {
<app-alert-message type="success" role="status" class="mb-2">
<div content>
<span>SMS sent.</span>
<span>
{{ response.provider }}:
{{ response.providerMessageId || response.status }}
</span>
<span>Phone number verified.</span>
</div>
</app-alert-message>
} @else if (verifyResponse && verifyResponse.success &&
!verifyResponse.verified) {
<app-alert-message type="warning" role="alert" class="mb-2">
<div content>
<span>That code is not correct. Please try again.</span>
</div>
</app-alert-message>
} @if (loading) {
Expand All @@ -93,7 +146,12 @@ <h1 class="orc-font-heading-small font-normal mb-4">SMS POC</h1>
[disabled]="loading"
[ngClass]="{ 'button-loading': loading }"
>
Send SMS
Verify code
</button>

<button mat-button type="button" (click)="startOver()">
Use a different number
</button>
</form>
}
</mat-card>
19 changes: 19 additions & 0 deletions src/app/sms-poc/pages/sms-poc/sms-poc.component.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
91 changes: 62 additions & 29 deletions src/app/sms-poc/pages/sms-poc/sms-poc.component.spec.ts
Original file line number Diff line number Diff line change
@@ -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'

Expand All @@ -14,12 +16,23 @@ describe('SmsPocComponent', () => {
let smsPocService: jasmine.SpyObj<SmsPocService>

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()
})
Expand All @@ -30,59 +43,79 @@ 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()

expect(smsPocService.send).not.toHaveBeenCalled()
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()
})
})
Loading
Loading