feat(console): phone number validation with flags (#5139)

Formats the phonenumber according to the preselected country
This commit is contained in:
Miguel Cabrerizo
2023-02-02 09:36:43 +01:00
committed by GitHub
parent e9d5d1dcaf
commit 5704c44117
284 changed files with 306 additions and 65 deletions

View File

@@ -139,13 +139,26 @@
<p class="user-create-section">{{ 'USER.CREATE.ADDRESSANDPHONESECTION' | translate }}</p>
<cnsl-form-field>
<cnsl-label>{{ 'USER.PROFILE.PHONE' | translate }}</cnsl-label>
<input cnslInput formControlName="phone" />
<span cnslError *ngIf="phone?.invalid && phone?.errors?.required">
{{ 'USER.VALIDATION.REQUIRED' | translate }}
</span>
</cnsl-form-field>
<div class="phone-grid">
<cnsl-form-field>
<cnsl-label>{{ 'USER.PROFILE.COUNTRY' | translate }}</cnsl-label>
<mat-select [(value)]="selected" (selectionChange)="setCountryCallingCode()" data-cy="country-calling-code">
<mat-select-trigger> <span class="fi fi-{{ selected?.countryCode | lowercase }}"></span></mat-select-trigger>
<mat-option *ngFor="let country of countryPhoneCodes" [value]="country">
<span class="fi fi-{{ country.countryCode | lowercase }}"></span>
<span class="phone-country-name">{{ country.countryName }}</span>
<span class="phone-country-code">+{{ country.countryCallingCode }}</span>
</mat-option>
</mat-select>
</cnsl-form-field>
<cnsl-form-field>
<cnsl-label>{{ 'USER.PROFILE.PHONE' | translate }}</cnsl-label>
<input cnslInput formControlName="phone" matTooltip="{{ 'USER.PROFILE.PHONE_HINT' | translate }}" />
<span cnslError *ngIf="phone?.invalid && phone?.errors?.required">
{{ 'USER.VALIDATION.REQUIRED' | translate }}
</span>
</cnsl-form-field>
</div>
</div>
<div class="user-create-btn-container">
<button

View File

@@ -53,3 +53,22 @@
}
}
}
.phone-grid {
max-width: 22rem;
display: grid;
grid-template-columns: auto 1fr;
column-gap: 1rem;
@media only screen and (max-width: 500px) {
grid-template-columns: 1fr;
}
}
.phone-country-name {
padding: 0 0.5em;
}
.phone-country-code {
color: darkgrey;
}

View File

@@ -1,10 +1,9 @@
import { Location } from '@angular/common';
import { ChangeDetectorRef, Component, OnDestroy, ViewChild } from '@angular/core';
import { ChangeDetectorRef, Component, OnInit, OnDestroy, ViewChild } from '@angular/core';
import { AbstractControl, UntypedFormBuilder, UntypedFormGroup, ValidatorFn, Validators } from '@angular/forms';
import { Router } from '@angular/router';
import parsePhoneNumber from 'libphonenumber-js';
import { parsePhoneNumber, CountryCode } from 'libphonenumber-js';
import { Subject } from 'rxjs';
import { debounceTime, takeUntil } from 'rxjs/operators';
import { AddHumanUserRequest } from 'src/app/proto/generated/zitadel/management_pb';
import { Domain } from 'src/app/proto/generated/zitadel/org_pb';
import { PasswordComplexityPolicy } from 'src/app/proto/generated/zitadel/policy_pb';
@@ -14,6 +13,8 @@ import { ManagementService } from 'src/app/services/mgmt.service';
import { ToastService } from 'src/app/services/toast.service';
import { lowerCaseValidator, numberValidator, symbolValidator, upperCaseValidator } from '../../validators';
import { CountryCallingCodesService, CountryPhoneCode } from 'src/app/services/country-calling-codes.service';
import { formatPhone } from 'src/app/utils/formatPhone';
function passwordConfirmValidator(c: AbstractControl): any {
if (!c.parent || !c) {
@@ -40,10 +41,12 @@ function passwordConfirmValidator(c: AbstractControl): any {
templateUrl: './user-create.component.html',
styleUrls: ['./user-create.component.scss'],
})
export class UserCreateComponent implements OnDestroy {
export class UserCreateComponent implements OnInit, OnDestroy {
public user: AddHumanUserRequest.AsObject = new AddHumanUserRequest().toObject();
public genders: Gender[] = [Gender.GENDER_FEMALE, Gender.GENDER_MALE, Gender.GENDER_UNSPECIFIED];
public languages: string[] = ['de', 'en', 'it', 'fr'];
public selected: CountryPhoneCode | undefined;
public countryPhoneCodes: CountryPhoneCode[] = [];
public userForm!: UntypedFormGroup;
public pwdForm!: UntypedFormGroup;
private destroyed$: Subject<void> = new Subject();
@@ -63,6 +66,7 @@ export class UserCreateComponent implements OnDestroy {
private mgmtService: ManagementService,
private changeDetRef: ChangeDetectorRef,
private _location: Location,
private countryCallingCodesService: CountryCallingCodesService,
breadcrumbService: BreadcrumbService,
) {
breadcrumbService.setBreadcrumb([
@@ -151,17 +155,6 @@ export class UserCreateComponent implements OnDestroy {
});
}
});
this.userForm.controls['phone'].valueChanges.pipe(takeUntil(this.destroyed$), debounceTime(300)).subscribe((value) => {
const phoneNumber = parsePhoneNumber(value ?? '', 'CH');
if (phoneNumber) {
const formmatted = phoneNumber.formatInternational();
const country = phoneNumber.country;
if (this.phone && country && this.phone.value && this.phone.value !== formmatted) {
this.phone.setValue(formmatted);
}
}
});
}
public createUser(): void {
@@ -190,7 +183,10 @@ export class UserCreateComponent implements OnDestroy {
}
if (this.phone && this.phone.value) {
humanReq.setPhone(new AddHumanUserRequest.Phone().setPhone(this.phone.value));
// Try to parse number and format it according to country
const phoneNumber = formatPhone(this.phone.value);
this.selected = this.countryPhoneCodes.find((code) => code.countryCode === phoneNumber.country);
humanReq.setPhone(new AddHumanUserRequest.Phone().setPhone(phoneNumber.phone));
}
this.mgmtService
@@ -206,6 +202,18 @@ export class UserCreateComponent implements OnDestroy {
});
}
public setCountryCallingCode(): void {
let value = (this.phone?.value as string) || '';
this.phone?.setValue('+' + this.selected?.countryCallingCode + ' ' + value.replace(/\+[0-9]*\s/, ''));
}
ngOnInit(): void {
// Set default selected country for phone numbers
const defaultCountryCallingCode = 'CH';
this.countryPhoneCodes = this.countryCallingCodesService.getCountryCallingCodes();
this.selected = this.countryPhoneCodes.find((code) => code.countryCode === defaultCountryCallingCode);
}
ngOnDestroy(): void {
this.destroyed$.next();
this.destroyed$.complete();

View File

@@ -18,9 +18,11 @@ import { PasswordComplexityViewModule } from 'src/app/modules/password-complexit
import { UserCreateRoutingModule } from './user-create-routing.module';
import { UserCreateComponent } from './user-create.component';
import { CountryCallingCodesService } from 'src/app/services/country-calling-codes.service';
@NgModule({
declarations: [UserCreateComponent],
providers: [CountryCallingCodesService],
imports: [
UserCreateRoutingModule,
CommonModule,

View File

@@ -21,6 +21,7 @@ import { Buffer } from 'buffer';
import { EditDialogComponent, EditDialogType } from './edit-dialog/edit-dialog.component';
import { PolicyComponentServiceType } from 'src/app/modules/policies/policy-component-types.enum';
import { LoginPolicy } from 'src/app/proto/generated/zitadel/policy_pb';
import { formatPhone } from 'src/app/utils/formatPhone';
@Component({
selector: 'cnsl-auth-user-detail',
@@ -271,6 +272,9 @@ export class AuthUserDetailComponent implements OnDestroy {
public savePhone(phone: string): void {
if (this.user?.human) {
// Format phone before save (add +)
phone = formatPhone(phone).phone;
this.userService
.setMyPhone(phone)
.then(() => {

View File

@@ -3,12 +3,31 @@
</h1>
<p class="desc cnsl-secondary-text">{{ data.descriptionKey | translate }}</p>
<div mat-dialog-content>
<cnsl-form-field class="formfield">
<cnsl-label
>{{ data.labelKey | translate }} <span *ngIf="isPhone && phoneCountry">({{ phoneCountry }})</span>
</cnsl-label>
<input [formControl]="valueControl" cnslInput (keydown.enter)="valueControl.valid ? closeDialogWithValue() : null" />
</cnsl-form-field>
<div class="phone-grid">
<cnsl-form-field *ngIf="isPhone">
<cnsl-label>{{ 'USER.PROFILE.COUNTRY' | translate }}</cnsl-label>
<mat-select [(value)]="selected" (selectionChange)="setCountryCallingCode()">
<mat-select-trigger> <span class="fi fi-{{ selected?.countryCode | lowercase }}"></span></mat-select-trigger>
<mat-option *ngFor="let country of countryPhoneCodes" [value]="country">
<span class="fi fi-{{ country.countryCode | lowercase }}"></span>
<span class="phone-country-name">{{ country.countryName }}</span>
<span class="phone-country-code">+{{ country.countryCallingCode }}</span>
</mat-option>
</mat-select>
</cnsl-form-field>
<cnsl-form-field>
<cnsl-label>{{ data.labelKey | translate }}</cnsl-label>
<input
cnslInput
[formControl]="valueControl"
matTooltip="{{ 'USER.PROFILE.PHONE_HINT' | translate }}"
[matTooltipDisabled]="!isPhone"
/>
<span cnslError *ngIf="valueControl?.invalid && valueControl?.errors?.required">
{{ 'USER.VALIDATION.REQUIRED' | translate }}
</span>
</cnsl-form-field>
</div>
<ng-container *ngIf="data.type === EditDialogType.EMAIL && data.isVerifiedTextKey">
<mat-checkbox class="verified-checkbox" [(ngModel)]="isVerified">

View File

@@ -22,3 +22,22 @@
border-radius: 0.5rem;
}
}
.phone-grid {
max-width: 22rem;
display: grid;
grid-template-columns: auto 1fr;
column-gap: 1rem;
@media only screen and (max-width: 500px) {
grid-template-columns: 1fr;
}
}
.phone-country-name {
padding: 0 0.5em;
}
.phone-country-code {
color: darkgrey;
}

View File

@@ -1,10 +1,12 @@
import { Component, Inject } from '@angular/core';
import { Component, Inject, OnInit } from '@angular/core';
import { UntypedFormControl, Validators } from '@angular/forms';
import {
MAT_LEGACY_DIALOG_DATA as MAT_DIALOG_DATA,
MatLegacyDialogRef as MatDialogRef,
} from '@angular/material/legacy-dialog';
import { parsePhoneNumber } from 'libphonenumber-js';
import { CountryCode, parsePhoneNumber } from 'libphonenumber-js';
import { CountryCallingCodesService, CountryPhoneCode } from 'src/app/services/country-calling-codes.service';
import { formatPhone } from 'src/app/utils/formatPhone';
export enum EditDialogType {
PHONE = 1,
@@ -16,39 +18,37 @@ export enum EditDialogType {
templateUrl: './edit-dialog.component.html',
styleUrls: ['./edit-dialog.component.scss'],
})
export class EditDialogComponent {
export class EditDialogComponent implements OnInit {
public isPhone: boolean = false;
public isVerified: boolean = false;
public phoneCountry: string = 'CH';
public valueControl: UntypedFormControl = new UntypedFormControl(['', [Validators.required]]);
public EditDialogType: any = EditDialogType;
constructor(public dialogRef: MatDialogRef<EditDialogComponent>, @Inject(MAT_DIALOG_DATA) public data: any) {
public selected: CountryPhoneCode | undefined;
public countryPhoneCodes: CountryPhoneCode[] = [];
constructor(
public dialogRef: MatDialogRef<EditDialogComponent>,
@Inject(MAT_DIALOG_DATA) public data: any,
private countryCallingCodesService: CountryCallingCodesService,
) {
this.valueControl.setValue(data.value);
if (data.type === EditDialogType.PHONE) {
this.isPhone = true;
}
this.valueControl.valueChanges.subscribe((value) => {
if (value && value.length > 1) {
this.changeValue(value);
}
});
}
private changeValue(changedValue: string): void {
if (this.isPhone && changedValue) {
try {
const phoneNumber = parsePhoneNumber(changedValue ?? '', 'CH');
if (phoneNumber) {
const formmatted = phoneNumber.formatInternational();
this.phoneCountry = phoneNumber.country || '';
if (formmatted !== this.valueControl.value) {
this.valueControl.setValue(formmatted);
}
}
} catch (error) {
console.error(error);
}
public setCountryCallingCode(): void {
let value = (this.valueControl?.value as string) || '';
this.valueControl?.setValue('+' + this.selected?.countryCallingCode + ' ' + value.replace(/\+[0-9]*\s/, ''));
}
ngOnInit(): void {
if (this.isPhone) {
// Get country phone codes and set selected flag to guessed country or default country
this.countryPhoneCodes = this.countryCallingCodesService.getCountryCallingCodes();
const phoneNumber = formatPhone(this.valueControl?.value);
this.selected = this.countryPhoneCodes.find((code) => code.countryCode === phoneNumber.country);
this.valueControl.setValue(phoneNumber.phone);
}
}

View File

@@ -36,7 +36,7 @@
<div class="contact-method-row">
<div class="left">
<span class="label cnsl-secondary-text">{{ 'USER.PHONE' | translate }}</span>
<span class="name">{{ human.phone?.phone ? human.phone?.phone : ('USER.PHONEEMPTY' | translate) }}</span>
<cnsl-phone-detail [phone]="human.phone?.phone"></cnsl-phone-detail>
<span *ngIf="human.phone?.isPhoneVerified" class="contact-state verified">{{ 'USER.PHONEVERIFIED' | translate }}</span>
<div *ngIf="human.phone?.phone && !human.phone?.isPhoneVerified" class="block">
<span class="contact-state notverified">{{ 'USER.NOTVERIFIED' | translate }}</span>

View File

@@ -0,0 +1,4 @@
<span class="name">
<span *ngIf="country" class="fi fi-{{ country | lowercase }} margin-right"></span>
{{ phone ? phone : ('USER.PHONEEMPTY' | translate) }}
</span>

View File

@@ -0,0 +1,3 @@
.margin-right {
margin-right: 0.5em;
}

View File

@@ -0,0 +1,24 @@
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
import { PhoneDetailComponent } from './phone-detail.component';
describe('PhoneDetailComponent', () => {
let component: PhoneDetailComponent;
let fixture: ComponentFixture<PhoneDetailComponent>;
beforeEach(waitForAsync(() => {
TestBed.configureTestingModule({
declarations: [PhoneDetailComponent],
}).compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(PhoneDetailComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@@ -0,0 +1,24 @@
import { Component, Input, OnChanges, SimpleChanges } from '@angular/core';
import { formatPhone } from 'src/app/utils/formatPhone';
@Component({
selector: 'cnsl-phone-detail',
templateUrl: './phone-detail.component.html',
styleUrls: ['./phone-detail.component.scss'],
})
export class PhoneDetailComponent implements OnChanges {
@Input() phone: string | undefined;
public country: string | undefined;
ngOnChanges(changes: SimpleChanges): void {
if (changes.phone.currentValue) {
const phoneNumber = formatPhone(changes.phone.currentValue);
if (this.phone !== phoneNumber.phone) {
this.phone = phoneNumber.phone;
this.country = phoneNumber.country;
}
} else {
this.country = undefined;
}
}
}

View File

@@ -58,6 +58,9 @@ import { UserMfaComponent } from './user-detail/user-mfa/user-mfa.component';
import { MachineSecretDialogComponent } from './user-detail/machine-secret-dialog/machine-secret-dialog.component';
import { MetadataModule } from 'src/app/modules/metadata/metadata.module';
import { QRCodeModule } from 'angularx-qrcode';
import { PhoneDetailComponent } from './phone-detail/phone-detail.component';
import { CountryCallingCodesService } from 'src/app/services/country-calling-codes.service';
import { MatLegacySelectModule as MatSelectModule } from '@angular/material/legacy-select';
@NgModule({
declarations: [
@@ -76,8 +79,10 @@ import { QRCodeModule } from 'angularx-qrcode';
DialogU2FComponent,
DialogPasswordlessComponent,
AuthFactorDialogComponent,
PhoneDetailComponent,
MachineSecretDialogComponent,
],
providers: [CountryCallingCodesService],
imports: [
ChangesModule,
CommonModule,
@@ -122,6 +127,7 @@ import { QRCodeModule } from 'angularx-qrcode';
InputModule,
MachineKeysModule,
InfoSectionModule,
MatSelectModule,
],
})
export class UserDetailModule {}

View File

@@ -21,6 +21,7 @@ import { Buffer } from 'buffer';
import { EditDialogComponent, EditDialogType } from '../auth-user-detail/edit-dialog/edit-dialog.component';
import { ResendEmailDialogComponent } from '../auth-user-detail/resend-email-dialog/resend-email-dialog.component';
import { LoginPolicy } from 'src/app/proto/generated/zitadel/policy_pb';
import { formatPhone } from 'src/app/utils/formatPhone';
import { MachineSecretDialogComponent } from './machine-secret-dialog/machine-secret-dialog.component';
const GENERAL: SidenavSetting = { id: 'general', i18nKey: 'USER.SETTINGS.GENERAL' };
@@ -355,6 +356,9 @@ export class UserDetailComponent implements OnInit {
public savePhone(phone: string): void {
if (this.user.id && phone) {
// Format phone before save (add +)
phone = formatPhone(phone).phone;
this.mgmtUserService
.updateHumanPhone(this.user.id, phone)
.then(() => {