[PM-10514] New TOTP Counter Component (#10486)

* new vault totp countdown component
This commit is contained in:
Jason Ng 2024-08-15 10:03:14 -04:00 committed by GitHub
parent 0b24789449
commit 72767dec74
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 149 additions and 20 deletions

View File

@ -9,11 +9,7 @@
</app-item-details-v2> </app-item-details-v2>
<!-- LOGIN CREDENTIALS --> <!-- LOGIN CREDENTIALS -->
<app-login-credentials-view <app-login-credentials-view *ngIf="hasLogin" [cipher]="cipher"></app-login-credentials-view>
*ngIf="hasLogin"
[login]="cipher.login"
[viewPassword]="cipher.viewPassword"
></app-login-credentials-view>
<!-- AUTOFILL OPTIONS --> <!-- AUTOFILL OPTIONS -->
<app-autofill-options-view *ngIf="hasAutofill" [loginUris]="cipher.login.uris"> <app-autofill-options-view *ngIf="hasAutofill" [loginUris]="cipher.login.uris">

View File

@ -3,7 +3,7 @@
<h2 bitTypography="h6">{{ "loginCredentials" | i18n }}</h2> <h2 bitTypography="h6">{{ "loginCredentials" | i18n }}</h2>
</bit-section-header> </bit-section-header>
<bit-card> <bit-card>
<bit-form-field [disableMargin]="!login.password && !login.totp"> <bit-form-field [disableMargin]="!cipher.login.password && !cipher.login.totp">
<bit-label> <bit-label>
{{ "username" | i18n }} {{ "username" | i18n }}
</bit-label> </bit-label>
@ -11,7 +11,7 @@
readonly readonly
bitInput bitInput
type="text" type="text"
[value]="login.username" [value]="cipher.login.username"
aria-readonly="true" aria-readonly="true"
data-testid="login-username" data-testid="login-username"
/> />
@ -19,20 +19,20 @@
bitIconButton="bwi-clone" bitIconButton="bwi-clone"
bitSuffix bitSuffix
type="button" type="button"
[appCopyClick]="login.username" [appCopyClick]="cipher.login.username"
[valueLabel]="'username' | i18n" [valueLabel]="'username' | i18n"
showToast showToast
[appA11yTitle]="'copyValue' | i18n" [appA11yTitle]="'copyValue' | i18n"
data-testid="toggle-username" data-testid="toggle-username"
></button> ></button>
</bit-form-field> </bit-form-field>
<bit-form-field [disableMargin]="!login.totp"> <bit-form-field [disableMargin]="!cipher.login.totp">
<bit-label>{{ "password" | i18n }}</bit-label> <bit-label>{{ "password" | i18n }}</bit-label>
<input <input
readonly readonly
bitInput bitInput
type="password" type="password"
[value]="login.password" [value]="cipher.login.password"
aria-readonly="true" aria-readonly="true"
data-testid="login-password" data-testid="login-password"
/> />
@ -45,7 +45,7 @@
(toggledChange)="pwToggleValue($event)" (toggledChange)="pwToggleValue($event)"
></button> ></button>
<button <button
*ngIf="viewPassword && passwordRevealed" *ngIf="cipher.viewPassword && passwordRevealed"
bitIconButton="bwi-numbered-list" bitIconButton="bwi-numbered-list"
bitSuffix bitSuffix
type="button" type="button"
@ -58,7 +58,7 @@
bitIconButton="bwi-clone" bitIconButton="bwi-clone"
bitSuffix bitSuffix
type="button" type="button"
[appCopyClick]="login.password" [appCopyClick]="cipher.login.password"
[valueLabel]="'password' | i18n" [valueLabel]="'password' | i18n"
showToast showToast
[appA11yTitle]="'copyValue' | i18n" [appA11yTitle]="'copyValue' | i18n"
@ -66,9 +66,12 @@
></button> ></button>
</bit-form-field> </bit-form-field>
<ng-container *ngIf="showPasswordCount && passwordRevealed"> <ng-container *ngIf="showPasswordCount && passwordRevealed">
<bit-color-password [password]="login.password" [showCount]="true"></bit-color-password> <bit-color-password
[password]="cipher.login.password"
[showCount]="true"
></bit-color-password>
</ng-container> </ng-container>
<bit-form-field disableMargin *ngIf="login.totp"> <bit-form-field disableMargin *ngIf="cipher.login.totp">
<bit-label <bit-label
>{{ "verificationCodeTotp" | i18n }} >{{ "verificationCodeTotp" | i18n }}
<span <span
@ -84,17 +87,25 @@
<input <input
readonly readonly
bitInput bitInput
type="text" [type]="!(isPremium$ | async) ? 'password' : 'text'"
[value]="login.totp" [value]="totpCopyCode || '*** ***'"
aria-readonly="true" aria-readonly="true"
data-testid="login-totp" data-testid="login-totp"
[disabled]="!(isPremium$ | async)" [disabled]="!(isPremium$ | async)"
/> />
<button
*ngIf="isPremium$ | async"
bitTotpCountdown
[cipher]="cipher"
bitSuffix
type="button"
(sendCopyCode)="setTotpCopyCode($event)"
></button>
<button <button
bitIconButton="bwi-clone" bitIconButton="bwi-clone"
bitSuffix bitSuffix
type="button" type="button"
[appCopyClick]="login.totp" [appCopyClick]="totpCopyCode"
[valueLabel]="'verificationCodeTotp' | i18n" [valueLabel]="'verificationCodeTotp' | i18n"
showToast showToast
[appA11yTitle]="'copyValue' | i18n" [appA11yTitle]="'copyValue' | i18n"

View File

@ -5,7 +5,7 @@ import { Observable, shareReplay } from "rxjs";
import { JslibModule } from "@bitwarden/angular/jslib.module"; import { JslibModule } from "@bitwarden/angular/jslib.module";
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions"; import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions";
import { LoginView } from "@bitwarden/common/vault/models/view/login.view"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { import {
CardComponent, CardComponent,
FormFieldModule, FormFieldModule,
@ -17,6 +17,8 @@ import {
ColorPasswordModule, ColorPasswordModule,
} from "@bitwarden/components"; } from "@bitwarden/components";
import { BitTotpCountdownComponent } from "../../components/totp-countdown/totp-countdown.component";
@Component({ @Component({
selector: "app-login-credentials-view", selector: "app-login-credentials-view",
templateUrl: "login-credentials-view.component.html", templateUrl: "login-credentials-view.component.html",
@ -32,17 +34,19 @@ import {
IconButtonModule, IconButtonModule,
BadgeModule, BadgeModule,
ColorPasswordModule, ColorPasswordModule,
BitTotpCountdownComponent,
], ],
}) })
export class LoginCredentialsViewComponent { export class LoginCredentialsViewComponent {
@Input() login: LoginView; @Input() cipher: CipherView;
@Input() viewPassword: boolean;
isPremium$: Observable<boolean> = isPremium$: Observable<boolean> =
this.billingAccountProfileStateService.hasPremiumFromAnySource$.pipe( this.billingAccountProfileStateService.hasPremiumFromAnySource$.pipe(
shareReplay({ refCount: true, bufferSize: 1 }), shareReplay({ refCount: true, bufferSize: 1 }),
); );
showPasswordCount: boolean = false; showPasswordCount: boolean = false;
passwordRevealed: boolean = false; passwordRevealed: boolean = false;
totpCopyCode: string;
constructor( constructor(
private billingAccountProfileStateService: BillingAccountProfileStateService, private billingAccountProfileStateService: BillingAccountProfileStateService,
@ -60,4 +64,8 @@ export class LoginCredentialsViewComponent {
togglePasswordCount() { togglePasswordCount() {
this.showPasswordCount = !this.showPasswordCount; this.showPasswordCount = !this.showPasswordCount;
} }
setTotpCopyCode(e: any) {
this.totpCopyCode = e;
}
} }

View File

@ -0,0 +1,34 @@
<div class="tw-flex tw-items-center tw-justify-center totp-v2">
<span class="tw-relative tw-flex tw-justify-center tw-items-center" aria-hidden="true">
<span
class="tw-absolute"
[ngClass]="{ 'tw-text-main': !totpLow, 'tw-text-danger': totpLow }"
bitTypography="helper"
>{{ totpSec }}</span
>
<svg class="tw-w-7 tw-h-7" transform="rotate(-90)">
<g>
<circle
class="tw-fill-none"
[ngClass]="{ 'tw-stroke-text-main': !totpLow, 'tw-stroke-danger-600': totpLow }"
r="9.5"
cy="11.8"
cx="11.8"
stroke-width="2"
stroke-dasharray="60"
[ngStyle]="{ 'stroke-dashoffset.px': totpDash }"
></circle>
<circle
class="tw-fill-none"
[ngClass]="{ 'tw-stroke-text-main': !totpLow, 'tw-stroke-danger-600': totpLow }"
r="11"
cy="11.8"
cx="11.8"
stroke-width="1"
stroke-dasharray="71"
stroke-dashoffset="0"
></circle>
</g>
</svg>
</span>
</div>

View File

@ -0,0 +1,80 @@
import { CommonModule } from "@angular/common";
import { Component, EventEmitter, Input, OnInit, Output } from "@angular/core";
import { TotpService } from "@bitwarden/common/vault/abstractions/totp.service";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { TypographyModule } from "@bitwarden/components";
@Component({
selector: "button[bitTotpCountdown]:not(button[bitButton])",
templateUrl: "totp-countdown.component.html",
standalone: true,
imports: [CommonModule, TypographyModule],
})
export class BitTotpCountdownComponent implements OnInit {
@Input() cipher: CipherView;
@Output() sendCopyCode = new EventEmitter();
totpCode: string;
totpCodeFormatted: string;
totpDash: number;
totpSec: number;
totpLow: boolean;
private totpInterval: any;
constructor(protected totpService: TotpService) {}
async ngOnInit() {
await this.totpUpdateCode();
const interval = this.totpService.getTimeInterval(this.cipher.login.totp);
await this.totpTick(interval);
this.totpInterval = setInterval(async () => {
await this.totpTick(interval);
}, 1000);
}
private async totpUpdateCode() {
if (this.cipher.login.totp == null) {
this.clearTotp();
return;
}
this.totpCode = await this.totpService.getCode(this.cipher.login.totp);
if (this.totpCode != null) {
if (this.totpCode.length > 4) {
this.totpCodeFormatted = this.formatTotpCode();
this.sendCopyCode.emit(this.totpCodeFormatted);
} else {
this.totpCodeFormatted = this.totpCode;
}
} else {
this.totpCodeFormatted = null;
this.sendCopyCode.emit(this.totpCodeFormatted);
this.clearTotp();
}
}
private async totpTick(intervalSeconds: number) {
const epoch = Math.round(new Date().getTime() / 1000.0);
const mod = epoch % intervalSeconds;
this.totpSec = intervalSeconds - mod;
this.totpDash = +(Math.round(((60 / intervalSeconds) * mod + "e+2") as any) + "e-2");
this.totpLow = this.totpSec <= 7;
if (mod === 0) {
await this.totpUpdateCode();
}
}
private formatTotpCode(): string {
const half = Math.floor(this.totpCode.length / 2);
return this.totpCode.substring(0, half) + " " + this.totpCode.substring(half);
}
private clearTotp() {
if (this.totpInterval) {
clearInterval(this.totpInterval);
}
}
}