1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-16 00:03:56 +00:00

SG-680 - Domain verification progress - (1) CopyDnsTxt added to state service (2) Refactored dialog to use async actions (3) Dialog form changes now mark form controls as touched for more responsive error handling

This commit is contained in:
Jared Snider
2022-12-09 16:53:29 -05:00
parent 964fab1782
commit 976d7c29b0
8 changed files with 113 additions and 79 deletions

View File

@@ -5538,6 +5538,9 @@
"verifyDomain": { "verifyDomain": {
"message": "Verify domain" "message": "Verify domain"
}, },
"reverifyDomain": {
"message": "Reverify domain"
},
"copyDnsTxtRecord": { "copyDnsTxtRecord": {
"message": "Copy DNS TXT record" "message": "Copy DNS TXT record"
}, },
@@ -5557,7 +5560,7 @@
"message": "Bitwarden will attempt to verify the domain 3 times during the first 72 hours. If the domain cant be verified, check the DNS record in your host and manually verify." "message": "Bitwarden will attempt to verify the domain 3 times during the first 72 hours. If the domain cant be verified, check the DNS record in your host and manually verify."
}, },
"invalidDomainNameMessage": { "invalidDomainNameMessage": {
"message": "Input is not a valid format. Example: mydomain.com. Subdomains require separate entries to be verified." "message": "Input is not a valid format. Format: mydomain.com. Subdomains require separate entries to be verified."
}, },
"domainRemoved": { "domainRemoved": {
"message": "Domain removed" "message": "Domain removed"

View File

@@ -1,20 +1,21 @@
<bit-dialog [dialogSize]="dialogSize" [disablePadding]="disablePadding"> <form [formGroup]="domainForm" [bitSubmit]>
<span bitDialogTitle> <bit-dialog [dialogSize]="dialogSize" [disablePadding]="disablePadding">
<span *ngIf="!data.orgDomain">{{ "newDomain" | i18n }}</span> <span bitDialogTitle>
<span *ngIf="data.orgDomain"> {{ "verifyDomain" | i18n }}</span> <span *ngIf="!data.orgDomain">{{ "newDomain" | i18n }}</span>
<span *ngIf="data.orgDomain"> {{ "verifyDomain" | i18n }}</span>
<span *ngIf="data.orgDomain" class="tw-text-xs tw-text-muted">{{ <span *ngIf="data.orgDomain" class="tw-text-xs tw-text-muted">{{
data.orgDomain.domainName data.orgDomain.domainName
}}</span> }}</span>
<!-- TODO: get status badge here --> <span *ngIf="data?.orgDomain && !data.orgDomain?.verifiedDate" bitBadge badgeType="warning"
</span> >Unverified</span
<div bitDialogContent> >
<form [formGroup]="domainForm" [bitSubmit]="verifyDomain"> <span *ngIf="data?.orgDomain && data?.orgDomain?.verifiedDate" bitBadge badgeType="success"
<!-- <input bitInput formControlName="domainName" /> --> >Verified</span
<!-- <span *ngIf="domainNameCtrl.touched && domainNameCtrl.invalid">Error</span> --> >
</span>
<!-- TODO: investigate why bit-form-field breaks touched --> <div bitDialogContent>
<bit-form-field> <bit-form-field>
<bit-label>{{ "domainName" | i18n }}</bit-label> <bit-label>{{ "domainName" | i18n }}</bit-label>
<input bitInput formControlName="domainName" /> <input bitInput formControlName="domainName" />
@@ -35,37 +36,40 @@
<i class="bwi bwi-lg bwi-clone" aria-hidden="true"></i> <i class="bwi bwi-lg bwi-clone" aria-hidden="true"></i>
</button> </button>
</bit-form-field> </bit-form-field>
</form>
<bit-callout type="info" title="{{ 'automaticDomainVerification' | i18n }}"> <bit-callout type="info" title="{{ 'automaticDomainVerification' | i18n }}">
{{ "automaticDomainVerificationProcess" | i18n }} {{ "automaticDomainVerificationProcess" | i18n }}
</bit-callout> </bit-callout>
</div> </div>
<div bitDialogFooter class="tw-flex tw-flex-row tw-items-center tw-gap-2"> <div bitDialogFooter class="tw-flex tw-flex-row tw-items-center tw-gap-2">
<button <button
type="submit" type="submit"
bitButton bitButton
buttonType="primary" bitFormButton
[disabled]="domainForm.invalid" buttonType="primary"
(click)="data.orgDomain ? verifyDomain() : saveDomain()" [disabled]="domainForm.invalid"
[loading]="submitting" [bitAction]="data.orgDomain ? verifyDomain : saveDomain"
> >
{{ "verifyDomain" | i18n }} <span *ngIf="!data?.orgDomain?.verifiedDate">{{ "verifyDomain" | i18n }}</span>
</button> <span *ngIf="data?.orgDomain?.verifiedDate">{{ "reverifyDomain" | i18n }}</span>
<button bitButton buttonType="secondary" [disabled]="submitting" (click)="dialogRef.close()"> </button>
{{ "cancel" | i18n }} <button bitButton buttonType="secondary" (click)="dialogRef.close()">
</button> {{ "cancel" | i18n }}
<button </button>
*ngIf="data.orgDomain"
class="tw-ml-auto" <button
bitIconButton="bwi-trash" *ngIf="data.orgDomain"
buttonType="danger" class="tw-ml-auto"
size="default" bitIconButton="bwi-trash"
title="Delete" buttonType="danger"
aria-label="Delete" size="default"
(click)="deleteDomain()" title="Delete"
[disabled]="submitting" aria-label="Delete"
[loading]="deleting" [bitAction]="deleteDomain"
></button> type="submit"
</div> bitButton
</bit-dialog> bitFormButton
></button>
</div>
</bit-dialog>
</form>

View File

@@ -1,10 +1,12 @@
import { DialogRef, DIALOG_DATA } from "@angular/cdk/dialog"; import { DialogRef, DIALOG_DATA } from "@angular/cdk/dialog";
import { Component, Inject, OnInit } from "@angular/core"; import { Component, Inject, OnDestroy, OnInit } from "@angular/core";
import { FormBuilder, FormControl, FormGroup, Validators } from "@angular/forms"; import { FormBuilder, FormControl, FormGroup, Validators } from "@angular/forms";
import { Subject, takeUntil } from "rxjs";
import { CryptoFunctionService as CryptoFunctionServiceAbstraction } from "@bitwarden/common/abstractions/cryptoFunction.service"; import { CryptoFunctionService as CryptoFunctionServiceAbstraction } from "@bitwarden/common/abstractions/cryptoFunction.service";
import { I18nService } from "@bitwarden/common/abstractions/i18n.service"; import { I18nService } from "@bitwarden/common/abstractions/i18n.service";
import { OrgDomainApiServiceAbstraction } from "@bitwarden/common/abstractions/organization-domain/org-domain-api.service.abstraction"; import { OrgDomainApiServiceAbstraction } from "@bitwarden/common/abstractions/organization-domain/org-domain-api.service.abstraction";
import { OrgDomainServiceAbstraction } from "@bitwarden/common/abstractions/organization-domain/org-domain.service.abstraction";
import { OrganizationDomainResponse } from "@bitwarden/common/abstractions/organization-domain/responses/organization-domain.response"; import { OrganizationDomainResponse } from "@bitwarden/common/abstractions/organization-domain/responses/organization-domain.response";
import { PlatformUtilsService } from "@bitwarden/common/abstractions/platformUtils.service"; import { PlatformUtilsService } from "@bitwarden/common/abstractions/platformUtils.service";
import { Utils } from "@bitwarden/common/misc/utils"; import { Utils } from "@bitwarden/common/misc/utils";
@@ -22,11 +24,11 @@ export interface DomainAddEditDialogData {
selector: "app-domain-add-edit-dialog", selector: "app-domain-add-edit-dialog",
templateUrl: "domain-add-edit-dialog.component.html", templateUrl: "domain-add-edit-dialog.component.html",
}) })
export class DomainAddEditDialogComponent implements OnInit { export class DomainAddEditDialogComponent implements OnInit, OnDestroy {
private componentDestroyed$: Subject<void> = new Subject();
dialogSize: "small" | "default" | "large" = "default"; dialogSize: "small" | "default" | "large" = "default";
disablePadding = false; disablePadding = false;
// TODO: should invalidDomainNameMessage have something like: "'https://', 'http://', or 'www.' domain prefixes not allowed."
domainForm: FormGroup = this.formBuilder.group({ domainForm: FormGroup = this.formBuilder.group({
domainName: [ domainName: [
"", "",
@@ -49,9 +51,6 @@ export class DomainAddEditDialogComponent implements OnInit {
return this.domainForm.controls.txt as FormControl; return this.domainForm.controls.txt as FormControl;
} }
submitting = false;
deleting = false;
constructor( constructor(
public dialogRef: DialogRef, public dialogRef: DialogRef,
@Inject(DIALOG_DATA) public data: DomainAddEditDialogData, @Inject(DIALOG_DATA) public data: DomainAddEditDialogData,
@@ -59,7 +58,8 @@ export class DomainAddEditDialogComponent implements OnInit {
private cryptoFunctionService: CryptoFunctionServiceAbstraction, private cryptoFunctionService: CryptoFunctionServiceAbstraction,
private platformUtilsService: PlatformUtilsService, private platformUtilsService: PlatformUtilsService,
private i18nService: I18nService, private i18nService: I18nService,
private orgDomainApiService: OrgDomainApiServiceAbstraction private orgDomainApiService: OrgDomainApiServiceAbstraction,
private orgDomainService: OrgDomainServiceAbstraction
) {} ) {}
async ngOnInit(): Promise<void> { async ngOnInit(): Promise<void> {
@@ -84,15 +84,20 @@ export class DomainAddEditDialogComponent implements OnInit {
)}`; )}`;
this.txtCtrl.setValue(generatedTxt); this.txtCtrl.setValue(generatedTxt);
} }
this.setupFormListeners();
}
setupFormListeners(): void {
// By default, <bit-form-field> suppresses touched state on change for reactive form control inputs
// I want validation errors to be shown as the user types (as validators are running on change anyhow by default).
this.domainForm.valueChanges.pipe(takeUntil(this.componentDestroyed$)).subscribe(() => {
this.domainForm.markAllAsTouched();
});
} }
copyDnsTxt(): void { copyDnsTxt(): void {
this.platformUtilsService.copyToClipboard(this.txtCtrl.value); this.orgDomainService.copyDnsTxt(this.txtCtrl.value);
this.platformUtilsService.showToast(
"success",
null,
this.i18nService.t("valueCopied", this.i18nService.t("dnsTxtRecord"))
);
} }
// TODO: error handling? // TODO: error handling?
@@ -104,8 +109,7 @@ export class DomainAddEditDialogComponent implements OnInit {
// If verified, no action can be taken but delete // If verified, no action can be taken but delete
// If saved & unverified, can prompt verification // If saved & unverified, can prompt verification
async saveDomain(): Promise<void> { saveDomain = async (): Promise<void> => {
this.submitting = true;
this.domainForm.disable(); this.domainForm.disable();
const request: OrganizationDomainRequest = new OrganizationDomainRequest( const request: OrganizationDomainRequest = new OrganizationDomainRequest(
@@ -118,14 +122,12 @@ export class DomainAddEditDialogComponent implements OnInit {
//TODO: figure out how to handle DomainVerifiedException //TODO: figure out how to handle DomainVerifiedException
this.platformUtilsService.showToast("success", null, this.i18nService.t("domainSaved")); this.platformUtilsService.showToast("success", null, this.i18nService.t("domainSaved"));
this.submitting = false;
// TODO: verify before closing modal; close if successful // TODO: verify before closing modal; close if successful
this.dialogRef.close(); this.dialogRef.close();
} };
async verifyDomain(): Promise<void> { verifyDomain = async (): Promise<void> => {
this.submitting = true;
this.domainForm.disable(); this.domainForm.disable();
const success: boolean = await this.orgDomainApiService.verify( const success: boolean = await this.orgDomainApiService.verify(
@@ -133,8 +135,6 @@ export class DomainAddEditDialogComponent implements OnInit {
this.data.orgDomain.id this.data.orgDomain.id
); );
this.submitting = false;
if (success) { if (success) {
this.platformUtilsService.showToast("success", null, this.i18nService.t("domainVerified")); this.platformUtilsService.showToast("success", null, this.i18nService.t("domainVerified"));
this.dialogRef.close(); this.dialogRef.close();
@@ -149,15 +149,19 @@ export class DomainAddEditDialogComponent implements OnInit {
// Someone else is using [domain]. Use a different domain to continue. // Someone else is using [domain]. Use a different domain to continue.
// I only have a bool to indicate success or failure.. not why it failed. // I only have a bool to indicate success or failure.. not why it failed.
} }
} };
async deleteDomain(): Promise<void> { deleteDomain = async (): Promise<void> => {
// TODO: Do I need an are you sure prompt? // TODO: Do I need an are you sure prompt? yes
this.deleting = true;
await this.orgDomainApiService.delete(this.data.organizationId, this.data.orgDomain.id); await this.orgDomainApiService.delete(this.data.organizationId, this.data.orgDomain.id);
this.deleting = false;
this.platformUtilsService.showToast("success", null, this.i18nService.t("domainRemoved")); this.platformUtilsService.showToast("success", null, this.i18nService.t("domainRemoved"));
this.dialogRef.close(); this.dialogRef.close();
};
ngOnDestroy(): void {
this.componentDestroyed$.next();
this.componentDestroyed$.complete();
} }
} }

View File

@@ -54,7 +54,7 @@
<i class="bwi bwi-ellipsis-v bwi-lg" aria-hidden="true"></i> <i class="bwi bwi-ellipsis-v bwi-lg" aria-hidden="true"></i>
</button> </button>
<bit-menu #orgDomainOptions> <bit-menu #orgDomainOptions>
<button bitMenuItem> <button bitMenuItem (click)="copyDnsTxt(orgDomain.txt)">
<i class="bwi bwi-fw bwi-clone" aria-hidden="true"></i> <i class="bwi bwi-fw bwi-clone" aria-hidden="true"></i>
Copy DNS TXT record Copy DNS TXT record
</button> </button>

View File

@@ -97,6 +97,14 @@ export class DomainVerificationComponent implements OnInit, OnDestroy {
return existingDomainNames; return existingDomainNames;
} }
//#region Options
copyDnsTxt(dnsTxt: string): void {
this.orgDomainService.copyDnsTxt(dnsTxt);
}
//#endregion
ngOnDestroy(): void { ngOnDestroy(): void {
this.componentDestroyed$.next(); this.componentDestroyed$.next();
this.componentDestroyed$.complete(); this.componentDestroyed$.complete();

View File

@@ -609,7 +609,7 @@ import { AbstractThemingService } from "./theming/theming.service.abstraction";
{ {
provide: OrgDomainServiceAbstraction, provide: OrgDomainServiceAbstraction,
useClass: OrgDomainService, useClass: OrgDomainService,
deps: [], deps: [PlatformUtilsServiceAbstraction, I18nServiceAbstraction],
}, },
{ {
provide: OrgDomainInternalServiceAbstraction, provide: OrgDomainInternalServiceAbstraction,

View File

@@ -6,6 +6,8 @@ export abstract class OrgDomainServiceAbstraction {
orgDomains$: Observable<OrganizationDomainResponse[]>; orgDomains$: Observable<OrganizationDomainResponse[]>;
get: (orgDomainId: string) => Promise<OrganizationDomainResponse>; get: (orgDomainId: string) => Promise<OrganizationDomainResponse>;
copyDnsTxt: (dnsTxt: string) => void;
} }
// Note: this separate class is designed to hold methods that are not // Note: this separate class is designed to hold methods that are not

View File

@@ -2,14 +2,18 @@ import { BehaviorSubject } from "rxjs";
import { OrgDomainInternalServiceAbstraction } from "../../abstractions/organization-domain/org-domain.service.abstraction"; import { OrgDomainInternalServiceAbstraction } from "../../abstractions/organization-domain/org-domain.service.abstraction";
import { OrganizationDomainResponse } from "../../abstractions/organization-domain/responses/organization-domain.response"; import { OrganizationDomainResponse } from "../../abstractions/organization-domain/responses/organization-domain.response";
import { PlatformUtilsService } from "../../abstractions/platformUtils.service";
import { I18nService } from "../i18n.service";
export class OrgDomainService implements OrgDomainInternalServiceAbstraction { export class OrgDomainService implements OrgDomainInternalServiceAbstraction {
protected _orgDomains$: BehaviorSubject<OrganizationDomainResponse[]> = new BehaviorSubject([]); protected _orgDomains$: BehaviorSubject<OrganizationDomainResponse[]> = new BehaviorSubject([]);
orgDomains$ = this._orgDomains$.asObservable(); orgDomains$ = this._orgDomains$.asObservable();
// eslint-disable-next-line @typescript-eslint/no-empty-function constructor(
constructor() {} private platformUtilsService: PlatformUtilsService,
private i18nService: I18nService
) {}
async get(orgDomainId: string): Promise<OrganizationDomainResponse> { async get(orgDomainId: string): Promise<OrganizationDomainResponse> {
const orgDomains: OrganizationDomainResponse[] = this._orgDomains$.getValue(); const orgDomains: OrganizationDomainResponse[] = this._orgDomains$.getValue();
@@ -17,6 +21,15 @@ export class OrgDomainService implements OrgDomainInternalServiceAbstraction {
return orgDomains.find((orgDomain) => orgDomain.id === orgDomainId); return orgDomains.find((orgDomain) => orgDomain.id === orgDomainId);
} }
copyDnsTxt(dnsTxt: string): void {
this.platformUtilsService.copyToClipboard(dnsTxt);
this.platformUtilsService.showToast(
"success",
null,
this.i18nService.t("valueCopied", this.i18nService.t("dnsTxtRecord"))
);
}
upsert(orgDomains: OrganizationDomainResponse[]): void { upsert(orgDomains: OrganizationDomainResponse[]): void {
const existingOrgDomains: OrganizationDomainResponse[] = this._orgDomains$.getValue(); const existingOrgDomains: OrganizationDomainResponse[] = this._orgDomains$.getValue();