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

refactor: move manage domain verification to admin-console, refs AC-1202 (#5077)

This commit is contained in:
Vincent Salucci
2023-03-27 14:40:02 -05:00
committed by GitHub
parent 092a2ba984
commit 7d4228687b
8 changed files with 3 additions and 3 deletions

View File

@@ -0,0 +1,75 @@
<form
[formGroup]="domainForm"
[bitSubmit]="data.orgDomain ? verifyDomain : saveDomain"
[allowDisabledFormSubmit]="true"
>
<bit-dialog [dialogSize]="'default'" [disablePadding]="false">
<span bitDialogTitle>
<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">{{
data.orgDomain.domainName
}}</span>
<span *ngIf="data?.orgDomain && !data.orgDomain?.verifiedDate" bitBadge badgeType="warning">{{
"domainStatusUnverified" | i18n
}}</span>
<span *ngIf="data?.orgDomain && data?.orgDomain?.verifiedDate" bitBadge badgeType="success">{{
"domainStatusVerified" | i18n
}}</span>
</span>
<div bitDialogContent>
<bit-form-field>
<bit-label>{{ "domainName" | i18n }}</bit-label>
<input bitInput appAutofocus formControlName="domainName" [showErrorsWhenDisabled]="true" />
<bit-hint>{{ "domainNameInputHint" | i18n }}</bit-hint>
</bit-form-field>
<bit-form-field>
<bit-label>{{ "dnsTxtRecord" | i18n }}</bit-label>
<input bitInput formControlName="txt" />
<bit-hint>{{ "dnsTxtRecordInputHint" | i18n }}</bit-hint>
<button
type="button"
bitSuffix
bitButton
appA11yTitle="{{ 'copyDnsTxtRecord' | i18n }}"
(click)="copyDnsTxt()"
>
<i class="bwi bwi-lg bwi-clone" aria-hidden="true"></i>
</button>
</bit-form-field>
<bit-callout
*ngIf="!data?.orgDomain?.verifiedDate"
type="info"
title="{{ 'automaticDomainVerification' | i18n }}"
>
{{ "automaticDomainVerificationProcess" | i18n }}
</bit-callout>
</div>
<div bitDialogFooter class="tw-flex tw-flex-row tw-items-center tw-gap-2">
<button type="submit" bitButton bitFormButton buttonType="primary">
<span *ngIf="!data?.orgDomain?.verifiedDate">{{ "verifyDomain" | i18n }}</span>
<span *ngIf="data?.orgDomain?.verifiedDate">{{ "reverifyDomain" | i18n }}</span>
</button>
<button bitButton buttonType="secondary" (click)="dialogRef.close()" type="button">
{{ "cancel" | i18n }}
</button>
<button
*ngIf="data.orgDomain"
class="tw-ml-auto"
bitIconButton="bwi-trash"
buttonType="danger"
size="default"
title="{{ 'delete' | i18n }}"
aria-label="Delete"
[bitAction]="deleteDomain"
type="submit"
bitFormButton
></button>
</div>
</bit-dialog>
</form>

View File

@@ -0,0 +1,270 @@
import { DialogRef, DIALOG_DATA } from "@angular/cdk/dialog";
import { Component, Inject, OnDestroy, OnInit } from "@angular/core";
import { FormBuilder, FormControl, FormGroup, ValidatorFn, Validators } from "@angular/forms";
import { Subject, takeUntil } from "rxjs";
import { CryptoFunctionService as CryptoFunctionServiceAbstraction } from "@bitwarden/common/abstractions/cryptoFunction.service";
import { I18nService } from "@bitwarden/common/abstractions/i18n.service";
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 { PlatformUtilsService } from "@bitwarden/common/abstractions/platformUtils.service";
import { ValidationService } from "@bitwarden/common/abstractions/validation.service";
import { HttpStatusCode } from "@bitwarden/common/enums/http-status-code.enum";
import { Utils } from "@bitwarden/common/misc/utils";
import { ErrorResponse } from "@bitwarden/common/models/response/error.response";
import { OrganizationDomainRequest } from "@bitwarden/common/services/organization-domain/requests/organization-domain.request";
import { domainNameValidator } from "./validators/domain-name.validator";
import { uniqueInArrayValidator } from "./validators/unique-in-array.validator";
export interface DomainAddEditDialogData {
organizationId: string;
orgDomain: OrganizationDomainResponse;
existingDomainNames: Array<string>;
}
@Component({
selector: "app-domain-add-edit-dialog",
templateUrl: "domain-add-edit-dialog.component.html",
})
export class DomainAddEditDialogComponent implements OnInit, OnDestroy {
private componentDestroyed$: Subject<void> = new Subject();
domainForm: FormGroup = this.formBuilder.group({
domainName: [
"",
[
Validators.required,
domainNameValidator(this.i18nService.t("invalidDomainNameMessage")),
uniqueInArrayValidator(
this.data.existingDomainNames,
this.i18nService.t("duplicateDomainError")
),
],
],
txt: [{ value: null, disabled: true }],
});
get domainNameCtrl(): FormControl {
return this.domainForm.controls.domainName as FormControl;
}
get txtCtrl(): FormControl {
return this.domainForm.controls.txt as FormControl;
}
rejectedDomainNameValidator: ValidatorFn = null;
rejectedDomainNames: Array<string> = [];
constructor(
public dialogRef: DialogRef,
@Inject(DIALOG_DATA) public data: DomainAddEditDialogData,
private formBuilder: FormBuilder,
private cryptoFunctionService: CryptoFunctionServiceAbstraction,
private platformUtilsService: PlatformUtilsService,
private i18nService: I18nService,
private orgDomainApiService: OrgDomainApiServiceAbstraction,
private orgDomainService: OrgDomainServiceAbstraction,
private validationService: ValidationService
) {}
//#region Angular Method Implementations
async ngOnInit(): Promise<void> {
// If we have data.orgDomain, then editing, otherwise creating new domain
await this.populateForm();
}
ngOnDestroy(): void {
this.componentDestroyed$.next();
this.componentDestroyed$.complete();
}
//#endregion
//#region Form methods
async populateForm(): Promise<void> {
if (this.data.orgDomain) {
// Edit
this.domainForm.patchValue(this.data.orgDomain);
this.domainForm.disable();
} else {
// Add
// Figuring out the proper length of our DNS TXT Record value was fun.
// DNS-Based Service Discovery RFC: https://www.ietf.org/rfc/rfc6763.txt; see section 6.1
// Google uses 43 chars for their TXT record value: https://support.google.com/a/answer/2716802
// So, chose a magic # of 33 bytes to achieve at least that once converted to base 64 (47 char length).
const generatedTxt = `bw=${Utils.fromBufferToB64(
await this.cryptoFunctionService.randomBytes(33)
)}`;
this.txtCtrl.setValue(generatedTxt);
}
this.setupFormListeners();
}
setupFormListeners(): void {
// <bit-form-field> suppresses touched state on change for reactive form controls
// Manually set touched to show validation errors as the user stypes
this.domainForm.valueChanges.pipe(takeUntil(this.componentDestroyed$)).subscribe(() => {
this.domainForm.markAllAsTouched();
});
}
copyDnsTxt(): void {
this.orgDomainService.copyDnsTxt(this.txtCtrl.value);
}
//#endregion
//#region Async Form Actions
saveDomain = async (): Promise<void> => {
if (this.domainForm.invalid) {
this.platformUtilsService.showToast("error", null, this.i18nService.t("domainFormInvalid"));
return;
}
this.domainNameCtrl.disable();
const request: OrganizationDomainRequest = new OrganizationDomainRequest(
this.txtCtrl.value,
this.domainNameCtrl.value
);
try {
this.data.orgDomain = await this.orgDomainApiService.post(this.data.organizationId, request);
this.platformUtilsService.showToast("success", null, this.i18nService.t("domainSaved"));
await this.verifyDomain();
} catch (e) {
this.handleDomainSaveError(e);
}
};
private handleDomainSaveError(e: any): void {
if (e instanceof ErrorResponse) {
const errorResponse: ErrorResponse = e as ErrorResponse;
switch (errorResponse.statusCode) {
case HttpStatusCode.Conflict:
if (errorResponse.message.includes("The domain is not available to be claimed")) {
// If user has attempted to claim a different rejected domain first:
if (this.rejectedDomainNameValidator) {
// Remove the validator:
this.domainNameCtrl.removeValidators(this.rejectedDomainNameValidator);
this.domainNameCtrl.updateValueAndValidity();
}
// Update rejected domain names and add new unique in validator
// which will prevent future known bad domain name submissions.
this.rejectedDomainNames.push(this.domainNameCtrl.value);
this.rejectedDomainNameValidator = uniqueInArrayValidator(
this.rejectedDomainNames,
this.i18nService.t("domainNotAvailable", this.domainNameCtrl.value)
);
this.domainNameCtrl.addValidators(this.rejectedDomainNameValidator);
this.domainNameCtrl.updateValueAndValidity();
// Give them another chance to enter a new domain name:
this.domainForm.enable();
} else {
this.validationService.showError(errorResponse);
}
break;
default:
this.validationService.showError(errorResponse);
break;
}
} else {
this.validationService.showError(e);
}
}
verifyDomain = async (): Promise<void> => {
if (this.domainForm.invalid) {
// Note: shouldn't be possible, but going to leave this to be safe.
this.platformUtilsService.showToast("error", null, this.i18nService.t("domainFormInvalid"));
return;
}
try {
this.data.orgDomain = await this.orgDomainApiService.verify(
this.data.organizationId,
this.data.orgDomain.id
);
if (this.data.orgDomain.verifiedDate) {
this.platformUtilsService.showToast("success", null, this.i18nService.t("domainVerified"));
this.dialogRef.close();
} else {
this.domainNameCtrl.setErrors({
errorPassthrough: {
message: this.i18nService.t("domainNotVerified", this.domainNameCtrl.value),
},
});
// For the case where user opens dialog and reverifies when domain name formControl disabled.
// The input directive only shows error if touched, so must manually mark as touched.
this.domainNameCtrl.markAsTouched();
// Update this item so the last checked date gets updated.
await this.updateOrgDomain();
}
} catch (e) {
this.handleVerifyDomainError(e, this.domainNameCtrl.value);
// Update this item so the last checked date gets updated.
await this.updateOrgDomain();
}
};
private handleVerifyDomainError(e: any, domainName: string): void {
if (e instanceof ErrorResponse) {
const errorResponse: ErrorResponse = e as ErrorResponse;
switch (errorResponse.statusCode) {
case HttpStatusCode.Conflict:
if (errorResponse.message.includes("The domain is not available to be claimed")) {
this.domainNameCtrl.setErrors({
errorPassthrough: {
message: this.i18nService.t("domainNotAvailable", domainName),
},
});
}
break;
default:
this.validationService.showError(errorResponse);
break;
}
}
}
private async updateOrgDomain() {
// Update this item so the last checked date gets updated.
await this.orgDomainApiService.getByOrgIdAndOrgDomainId(
this.data.organizationId,
this.data.orgDomain.id
);
}
deleteDomain = async (): Promise<void> => {
const confirmed = await this.platformUtilsService.showDialog(
this.i18nService.t("removeDomainWarning"),
this.i18nService.t("removeDomain"),
this.i18nService.t("yes"),
this.i18nService.t("no"),
"warning"
);
if (!confirmed) {
return;
}
await this.orgDomainApiService.delete(this.data.organizationId, this.data.orgDomain.id);
this.platformUtilsService.showToast("success", null, this.i18nService.t("domainRemoved"));
this.dialogRef.close();
};
//#endregion
}

View File

@@ -0,0 +1,47 @@
import { AbstractControl, ValidationErrors, ValidatorFn } from "@angular/forms";
export function domainNameValidator(errorMessage: string): ValidatorFn {
return (control: AbstractControl): ValidationErrors | null => {
const value = control.value;
if (!value) {
return null;
}
// Domain labels (sections) are only allowed to be 63 chars in length max
// 1st and last chars cannot be hyphens per RFC 3696 (https://www.rfc-editor.org/rfc/rfc3696#section-2)
// We do not want any prefixes per industry standards.
// Must support top-level domains and any number of subdomains.
// / # start regex
// ^ # start of string
// (?!(http(s)?:\/\/|www\.)) # negative lookahead to check if input doesn't match "http://", "https://" or "www."
// [a-zA-Z0-9] # first character must be a letter or a number
// [a-zA-Z0-9-]{0,61} # domain name can have 0 to 61 characters that are letters, numbers, or hyphens
// [a-zA-Z0-9] # domain name must end with a letter or a number
// (?: # start of non-capturing group (subdomain sections are optional)
// \. # subdomain must have a period
// [a-zA-Z0-9] # first character of subdomain must be a letter or a number
// [a-zA-Z0-9-]{0,61} # subdomain can have 0 to 61 characters that are letters, numbers, or hyphens
// [a-zA-Z0-9] # subdomain must end with a letter or a number
// )* # end of non-capturing group (subdomain sections are optional)
// \. # domain name must have a period
// [a-zA-Z]{2,} # domain name must have at least two letters (the domain extension)
// $/ # end of string
const validDomainNameRegex =
/^(?!(http(s)?:\/\/|www\.))[a-zA-Z0-9][a-zA-Z0-9-]{0,61}[a-zA-Z0-9](?:\.[a-zA-Z0-9][a-zA-Z0-9-]{0,61}[a-zA-Z0-9])*\.[a-zA-Z]{2,}$/;
const invalid = !validDomainNameRegex.test(control.value);
if (invalid) {
return {
invalidDomainName: {
message: errorMessage,
},
};
}
return null;
};
}

View File

@@ -0,0 +1,23 @@
import { AbstractControl, ValidationErrors, ValidatorFn } from "@angular/forms";
export function uniqueInArrayValidator(values: Array<string>, errorMessage: string): ValidatorFn {
return (control: AbstractControl): ValidationErrors | null => {
const value = control.value;
if (!value) {
return null;
}
const lowerTrimmedValue = value.toLowerCase().trim();
// check if the entered value is unique
if (values.some((val) => val.toLowerCase().trim() === lowerTrimmedValue)) {
return {
nonUniqueValue: {
message: errorMessage,
},
};
}
return null;
};
}

View File

@@ -0,0 +1,111 @@
<div class="tw-flex tw-flex-row tw-justify-between">
<h1>{{ "domainVerification" | i18n }}</h1>
<button *ngIf="!loading" type="button" buttonType="primary" bitButton (click)="addDomain()">
<i class="bwi bwi-plus bwi-fw" aria-hidden="true"></i> {{ "newDomain" | i18n }}
</button>
</div>
<ng-container *ngIf="loading">
<i
class="bwi bwi-spinner bwi-spin text-muted"
title="{{ 'loading' | i18n }}"
aria-hidden="true"
></i>
<span class="sr-only">{{ "loading" | i18n }}</span>
</ng-container>
<ng-container *ngIf="!loading">
<ng-container *ngIf="orgDomains$ | async as orgDomains">
<div class="tw-flex tw-flex-row">
<bit-table class="tw-w-full tw-table-auto">
<ng-container header>
<tr>
<th bitCell>{{ "name" | i18n }}</th>
<th bitCell>{{ "status" | i18n }}</th>
<th bitCell>{{ "lastChecked" | i18n }}</th>
<th bitCell class="tw-text-right">{{ "options" | i18n }}</th>
</tr>
</ng-container>
<ng-template body>
<tr bitRow *ngFor="let orgDomain of orgDomains; index as i">
<td bitCell>
<button
bitLink
type="button"
linkType="primary"
(click)="editDomain(orgDomain)"
appA11yTitle="{{ 'editDomain' | i18n }}"
>
{{ orgDomain.domainName }}
</button>
</td>
<td bitCell>
<span *ngIf="!orgDomain?.verifiedDate" bitBadge badgeType="warning">{{
"domainStatusUnverified" | i18n
}}</span>
<span *ngIf="orgDomain?.verifiedDate" bitBadge badgeType="success">{{
"domainStatusVerified" | i18n
}}</span>
</td>
<td bitCell class="tw-text-muted">
{{ orgDomain.lastCheckedDate | date : "medium" }}
</td>
<td bitCell class="table-list-options tw-text-right">
<button
[bitMenuTriggerFor]="orgDomainOptions"
class="tw-border-none tw-bg-transparent tw-text-main"
type="button"
appA11yTitle="{{ 'options' | i18n }}"
>
<i class="bwi bwi-ellipsis-v bwi-lg" aria-hidden="true"></i>
</button>
<bit-menu #orgDomainOptions>
<button bitMenuItem (click)="copyDnsTxt(orgDomain.txt)" type="button">
<i class="bwi bwi-fw bwi-clone" aria-hidden="true"></i>
{{ "copyDnsTxtRecord" | i18n }}
</button>
<button
bitMenuItem
(click)="verifyDomain(orgDomain.id, orgDomain.domainName)"
type="button"
>
<i class="bwi bwi-fw bwi-check" aria-hidden="true"></i>
{{ "verifyDomain" | i18n }}
</button>
<button bitMenuItem (click)="deleteDomain(orgDomain.id)" type="button">
<span class="tw-text-danger">
<i class="bwi bwi-fw bwi-close" aria-hidden="true"></i>
{{ "remove" | i18n }}
</span>
</button>
</bit-menu>
</td>
</tr>
</ng-template>
</bit-table>
</div>
<div
class="tw-mt-6 tw-flex tw-flex-col tw-items-center tw-justify-center"
*ngIf="orgDomains?.length == 0"
>
<img src="../../images/domain-verification/domain.svg" class="tw-mb-4" alt="" />
<div class="tw-mb-2 tw-flex tw-flex-row tw-justify-center">
<span class="tw-text-lg tw-font-bold">{{ "noDomains" | i18n }}</span>
</div>
<div class="tw-mb-4 tw-flex tw-flex-row tw-justify-center">
<span>
{{ "noDomainsSubText" | i18n }}
</span>
</div>
<button type="button" buttonType="secondary" bitButton (click)="addDomain()">
<i class="bwi bwi-plus bwi-fw" aria-hidden="true"></i> {{ "newDomain" | i18n }}
</button>
</div>
</ng-container>
</ng-container>

View File

@@ -0,0 +1,179 @@
import { Component, OnDestroy, OnInit } from "@angular/core";
import { ActivatedRoute, Params } from "@angular/router";
import { concatMap, Observable, Subject, take, takeUntil } from "rxjs";
import { I18nService } from "@bitwarden/common/abstractions/i18n.service";
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 { PlatformUtilsService } from "@bitwarden/common/abstractions/platformUtils.service";
import { ValidationService } from "@bitwarden/common/abstractions/validation.service";
import { HttpStatusCode } from "@bitwarden/common/enums/http-status-code.enum";
import { ErrorResponse } from "@bitwarden/common/models/response/error.response";
import { DialogService } from "@bitwarden/components";
import {
DomainAddEditDialogComponent,
DomainAddEditDialogData,
} from "./domain-add-edit-dialog/domain-add-edit-dialog.component";
@Component({
selector: "app-org-manage-domain-verification",
templateUrl: "domain-verification.component.html",
})
export class DomainVerificationComponent implements OnInit, OnDestroy {
private componentDestroyed$ = new Subject<void>();
loading = true;
organizationId: string;
orgDomains$: Observable<OrganizationDomainResponse[]>;
constructor(
private route: ActivatedRoute,
private platformUtilsService: PlatformUtilsService,
private i18nService: I18nService,
private orgDomainApiService: OrgDomainApiServiceAbstraction,
private orgDomainService: OrgDomainServiceAbstraction,
private dialogService: DialogService,
private validationService: ValidationService
) {}
// eslint-disable-next-line @typescript-eslint/no-empty-function
async ngOnInit() {
this.orgDomains$ = this.orgDomainService.orgDomains$;
// Note: going to use concatMap as async subscribe blocks don't work as you expect and
// as such, ESLint rejects it
// ex: https://stackoverflow.com/a/71056380
this.route.params
.pipe(
concatMap(async (params: Params) => {
this.organizationId = params.organizationId;
await this.load();
}),
takeUntil(this.componentDestroyed$)
)
.subscribe();
}
async load() {
await this.orgDomainApiService.getAllByOrgId(this.organizationId);
this.loading = false;
}
addDomain() {
const domainAddEditDialogData: DomainAddEditDialogData = {
organizationId: this.organizationId,
orgDomain: null,
existingDomainNames: this.getExistingDomainNames(),
};
this.dialogService.open(DomainAddEditDialogComponent, {
data: domainAddEditDialogData,
});
}
editDomain(orgDomain: OrganizationDomainResponse) {
const domainAddEditDialogData: DomainAddEditDialogData = {
organizationId: this.organizationId,
orgDomain: orgDomain,
existingDomainNames: this.getExistingDomainNames(),
};
this.dialogService.open(DomainAddEditDialogComponent, {
data: domainAddEditDialogData,
});
}
private getExistingDomainNames(): Array<string> {
let existingDomainNames: string[];
// eslint-disable-next-line rxjs-angular/prefer-takeuntil
this.orgDomains$.pipe(take(1)).subscribe((orgDomains: Array<OrganizationDomainResponse>) => {
existingDomainNames = orgDomains.map((o) => o.domainName);
});
return existingDomainNames;
}
//#region Options
copyDnsTxt(dnsTxt: string): void {
this.orgDomainService.copyDnsTxt(dnsTxt);
}
async verifyDomain(orgDomainId: string, domainName: string): Promise<void> {
try {
const orgDomain: OrganizationDomainResponse = await this.orgDomainApiService.verify(
this.organizationId,
orgDomainId
);
if (orgDomain.verifiedDate) {
this.platformUtilsService.showToast("success", null, this.i18nService.t("domainVerified"));
} else {
this.platformUtilsService.showToast(
"error",
null,
this.i18nService.t("domainNotVerified", domainName)
);
// Update this item so the last checked date gets updated.
await this.updateOrgDomain(orgDomainId);
}
} catch (e) {
this.handleVerifyDomainError(e, domainName);
// Update this item so the last checked date gets updated.
await this.updateOrgDomain(orgDomainId);
}
}
private async updateOrgDomain(orgDomainId: string) {
// Update this item so the last checked date gets updated.
await this.orgDomainApiService.getByOrgIdAndOrgDomainId(this.organizationId, orgDomainId);
}
private handleVerifyDomainError(e: any, domainName: string): void {
if (e instanceof ErrorResponse) {
const errorResponse: ErrorResponse = e as ErrorResponse;
switch (errorResponse.statusCode) {
case HttpStatusCode.Conflict:
if (errorResponse.message.includes("The domain is not available to be claimed")) {
this.platformUtilsService.showToast(
"error",
null,
this.i18nService.t("domainNotAvailable", domainName)
);
}
break;
default:
this.validationService.showError(errorResponse);
break;
}
}
}
async deleteDomain(orgDomainId: string): Promise<void> {
const confirmed = await this.platformUtilsService.showDialog(
this.i18nService.t("removeDomainWarning"),
this.i18nService.t("removeDomain"),
this.i18nService.t("yes"),
this.i18nService.t("no"),
"warning"
);
if (!confirmed) {
return;
}
await this.orgDomainApiService.delete(this.organizationId, orgDomainId);
this.platformUtilsService.showToast("success", null, this.i18nService.t("domainRemoved"));
}
//#endregion
ngOnDestroy(): void {
this.componentDestroyed$.next();
this.componentDestroyed$.complete();
}
}

View File

@@ -9,8 +9,8 @@ import { OrganizationLayoutComponent } from "@bitwarden/web-vault/app/admin-cons
import { SettingsComponent } from "@bitwarden/web-vault/app/admin-console/organizations/settings/settings.component";
import { SsoComponent } from "../../auth/sso/sso.component";
import { DomainVerificationComponent } from "../../organizations/manage/domain-verification/domain-verification.component";
import { DomainVerificationComponent } from "./manage/domain-verification/domain-verification.component";
import { ScimComponent } from "./manage/scim.component";
const routes: Routes = [

View File

@@ -3,10 +3,10 @@ import { NgModule } from "@angular/core";
import { SharedModule } from "@bitwarden/web-vault/app/shared/shared.module";
import { SsoComponent } from "../../auth/sso/sso.component";
import { DomainAddEditDialogComponent } from "../../organizations/manage/domain-verification/domain-add-edit-dialog/domain-add-edit-dialog.component";
import { DomainVerificationComponent } from "../../organizations/manage/domain-verification/domain-verification.component";
import { InputCheckboxComponent } from "./components/input-checkbox.component";
import { DomainAddEditDialogComponent } from "./manage/domain-verification/domain-add-edit-dialog/domain-add-edit-dialog.component";
import { DomainVerificationComponent } from "./manage/domain-verification/domain-verification.component";
import { ScimComponent } from "./manage/scim.component";
import { OrganizationsRoutingModule } from "./organizations-routing.module";